From 7eb1617ca658f51badecf2b4da156fdc95127afc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:01:44 +0000 Subject: [PATCH 01/11] Initial plan From c8c0126f1487cd6d28692cba4d3a6e4c91c4ef20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:09:35 +0000 Subject: [PATCH 02/11] Add experimental Godot GSDK implementation Co-authored-by: dgkanatsios <8256138+dgkanatsios@users.noreply.github.com> --- experimental/godot/README.md | 128 +++++++++ .../godot/addons/playfab_gsdk/gsdk.gd | 120 +++++++++ .../godot/addons/playfab_gsdk/gsdk_logger.gd | 52 ++++ .../addons/playfab_gsdk/internal_gsdk.gd | 242 ++++++++++++++++++ .../playfab_gsdk/playfab_gsdk_plugin.gd | 14 + .../godot/addons/playfab_gsdk/plugin.cfg | 7 + .../godot/addons/playfab_gsdk/types.gd | 74 ++++++ 7 files changed, 637 insertions(+) create mode 100644 experimental/godot/README.md create mode 100644 experimental/godot/addons/playfab_gsdk/gsdk.gd create mode 100644 experimental/godot/addons/playfab_gsdk/gsdk_logger.gd create mode 100644 experimental/godot/addons/playfab_gsdk/internal_gsdk.gd create mode 100644 experimental/godot/addons/playfab_gsdk/playfab_gsdk_plugin.gd create mode 100644 experimental/godot/addons/playfab_gsdk/plugin.cfg create mode 100644 experimental/godot/addons/playfab_gsdk/types.gd diff --git a/experimental/godot/README.md b/experimental/godot/README.md new file mode 100644 index 00000000..de787b82 --- /dev/null +++ b/experimental/godot/README.md @@ -0,0 +1,128 @@ +# 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 :) + +This version of GSDK is similar to the existing ones (C#, Java, C++, Go), apart from the fact that it contains a `mark_allocated()` method which internally sets the game server to active. + +## 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()`. +- `mark_allocated()` works only when the game server is in StandingBy state, so it should be called after `ready_for_players()` is called. For the time being, this method *should not* be used since it requires configuration settings on the Multiplayer Servers backend for each title. +- `RequestMultiplayerServer` API must *not* be called on a Build that uses `mark_allocated()`. It will probably work on the server side, but there is a small chance of concurrency issues if the two operations (`RequestMultiplayerServer` and `mark_allocated`) happen at the same time. + +## 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) +``` + +### Using `mark_allocated()` (Experimental) + +```gdscript +extends Node + +func _ready() -> void: + PlayFabGSDK.register_health_callback(func() -> bool: return true) + PlayFabGSDK.register_shutdown_callback(func() -> void: + PlayFabGSDK.log_message("Shutting down") + ) + PlayFabGSDK.start() + _do_mark_allocated() + +func _do_mark_allocated() -> void: + # Start a timer to mark allocated after some condition is met + await get_tree().create_timer(300.0).timeout + PlayFabGSDK.log_message("Marking allocated") + var success := PlayFabGSDK.mark_allocated() + if not success: + PlayFabGSDK.log_message("Failed to mark allocated") +``` + +### Updating Connected Players + +```gdscript +# Tell PlayFab about connected players +PlayFabGSDK.update_connected_players([ + {"PlayerId": "player-1"}, + {"PlayerId": "player-2"}, +]) +``` + +### 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..05f613f1 --- /dev/null +++ b/experimental/godot/addons/playfab_gsdk/gsdk.gd @@ -0,0 +1,120 @@ +## 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. +extends Node + +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.transition_to_active + return _internal.current_game_state == PlayFabGsdkTypes.GameState.ACTIVE + + +## [b]Experimental[/b] — Transitions the game server state to Active.[br] +## Only works when the game server is in StandingBy state. +## Returns [code]true[/code] on success, [code]false[/code] on failure. +func mark_allocated() -> bool: + if _internal.current_game_state != PlayFabGsdkTypes.GameState.STANDING_BY: + push_error("GSDK: Game server is not in the StandingBy state to be marked allocated") + return false + _internal.current_game_state = PlayFabGsdkTypes.GameState.ACTIVE + _internal.transition_to_active.emit() + return true + + +## 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..762a573e --- /dev/null +++ b/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd @@ -0,0 +1,52 @@ +## 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(directory): + DirAccess.make_dir_recursive_absolute(directory) + + var timestamp := int(Time.get_unix_time_from_system()) + var filename := "%s/GSDK_output_%d.txt" % [directory, timestamp] + _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..88cdb19e --- /dev/null +++ b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd @@ -0,0 +1,242 @@ +## 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. +extends Node + +## Emitted when the game server transitions to the Active state. +signal transition_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 + + +## 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) + + _started = true + + +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: + initial_players = PackedStringArray(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 + if response.has("nextScheduledMaintenanceUtc"): + var 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 + transition_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 + transition_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", "")) + cmap[PlayFabGsdkTypes.SHARED_CONTENT_FOLDER_KEY] = str(configuration.get("sharedContentFolder", "")) + 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("IpV4Address", "")) + 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..cd7a2cc6 --- /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 := "IpV4Address" +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 From c803dd524020120d840864aa95f47162007d2312 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:11:14 +0000 Subject: [PATCH 03/11] Address code review: fix log filename collision, safe string conversion, improve README example Co-authored-by: dgkanatsios <8256138+dgkanatsios@users.noreply.github.com> --- experimental/godot/README.md | 15 +++++++++++---- .../godot/addons/playfab_gsdk/gsdk_logger.gd | 3 ++- .../godot/addons/playfab_gsdk/internal_gsdk.gd | 5 ++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/experimental/godot/README.md b/experimental/godot/README.md index de787b82..4709dc39 100644 --- a/experimental/godot/README.md +++ b/experimental/godot/README.md @@ -31,7 +31,7 @@ Alternatively, you can manually add the autoload: ## 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()`. -- `mark_allocated()` works only when the game server is in StandingBy state, so it should be called after `ready_for_players()` is called. For the time being, this method *should not* be used since it requires configuration settings on the Multiplayer Servers backend for each title. +- `mark_allocated()` works only when the game server is in StandingBy state. Call `ready_for_players()` first (without `await`) to transition to StandingBy, then call `mark_allocated()` while `ready_for_players()` is still waiting for activation. For the time being, this method *should not* be used since it requires configuration settings on the Multiplayer Servers backend for each title. - `RequestMultiplayerServer` API must *not* be called on a Build that uses `mark_allocated()`. It will probably work on the server side, but there is a small chance of concurrency issues if the two operations (`RequestMultiplayerServer` and `mark_allocated`) happen at the same time. ## Configuration @@ -90,10 +90,17 @@ func _ready() -> void: PlayFabGSDK.log_message("Shutting down") ) PlayFabGSDK.start() - _do_mark_allocated() -func _do_mark_allocated() -> void: - # Start a timer to mark allocated after some condition is met + # Schedule mark_allocated to fire after 300 seconds (runs concurrently) + _schedule_mark_allocated() + + # ready_for_players transitions to StandingBy and awaits Active + PlayFabGSDK.log_message("Before ReadyForPlayers") + var is_active := await PlayFabGSDK.ready_for_players() + PlayFabGSDK.log_message("After ReadyForPlayers, active: %s" % str(is_active)) + +# Called without await so it runs concurrently with ready_for_players +func _schedule_mark_allocated() -> void: await get_tree().create_timer(300.0).timeout PlayFabGSDK.log_message("Marking allocated") var success := PlayFabGSDK.mark_allocated() diff --git a/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd b/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd index 762a573e..d4b66b9a 100644 --- a/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd +++ b/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd @@ -12,7 +12,8 @@ func initialize(directory: String) -> void: DirAccess.make_dir_recursive_absolute(directory) var timestamp := int(Time.get_unix_time_from_system()) - var filename := "%s/GSDK_output_%d.txt" % [directory, timestamp] + 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) diff --git a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd index 88cdb19e..101a5ab1 100644 --- a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd +++ b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd @@ -149,7 +149,10 @@ func _update_state_from_heartbeat(response: Dictionary) -> void: if session_config.has("initialPlayers") and session_config["initialPlayers"] is Array: var players: Array = session_config["initialPlayers"] if players.size() > 0: - initial_players = PackedStringArray(players) + 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"] From f50d61f795f0b41361d09ad120256b8f1bbc637a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:16:26 +0000 Subject: [PATCH 04/11] Remove mark_allocated() to match non-experimental GSDK API surface Co-authored-by: dgkanatsios <8256138+dgkanatsios@users.noreply.github.com> --- experimental/godot/README.md | 33 ------------------- .../godot/addons/playfab_gsdk/gsdk.gd | 12 ------- 2 files changed, 45 deletions(-) diff --git a/experimental/godot/README.md b/experimental/godot/README.md index 4709dc39..8f78e443 100644 --- a/experimental/godot/README.md +++ b/experimental/godot/README.md @@ -2,8 +2,6 @@ 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 :) -This version of GSDK is similar to the existing ones (C#, Java, C++, Go), apart from the fact that it contains a `mark_allocated()` method which internally sets the game server to active. - ## Requirements - Godot Engine 4.x @@ -31,8 +29,6 @@ Alternatively, you can manually add the autoload: ## 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()`. -- `mark_allocated()` works only when the game server is in StandingBy state. Call `ready_for_players()` first (without `await`) to transition to StandingBy, then call `mark_allocated()` while `ready_for_players()` is still waiting for activation. For the time being, this method *should not* be used since it requires configuration settings on the Multiplayer Servers backend for each title. -- `RequestMultiplayerServer` API must *not* be called on a Build that uses `mark_allocated()`. It will probably work on the server side, but there is a small chance of concurrency issues if the two operations (`RequestMultiplayerServer` and `mark_allocated`) happen at the same time. ## Configuration @@ -79,35 +75,6 @@ func _on_maintenance(maintenance_time: String) -> void: PlayFabGSDK.log_message("Maintenance scheduled at: %s" % maintenance_time) ``` -### Using `mark_allocated()` (Experimental) - -```gdscript -extends Node - -func _ready() -> void: - PlayFabGSDK.register_health_callback(func() -> bool: return true) - PlayFabGSDK.register_shutdown_callback(func() -> void: - PlayFabGSDK.log_message("Shutting down") - ) - PlayFabGSDK.start() - - # Schedule mark_allocated to fire after 300 seconds (runs concurrently) - _schedule_mark_allocated() - - # ready_for_players transitions to StandingBy and awaits Active - PlayFabGSDK.log_message("Before ReadyForPlayers") - var is_active := await PlayFabGSDK.ready_for_players() - PlayFabGSDK.log_message("After ReadyForPlayers, active: %s" % str(is_active)) - -# Called without await so it runs concurrently with ready_for_players -func _schedule_mark_allocated() -> void: - await get_tree().create_timer(300.0).timeout - PlayFabGSDK.log_message("Marking allocated") - var success := PlayFabGSDK.mark_allocated() - if not success: - PlayFabGSDK.log_message("Failed to mark allocated") -``` - ### Updating Connected Players ```gdscript diff --git a/experimental/godot/addons/playfab_gsdk/gsdk.gd b/experimental/godot/addons/playfab_gsdk/gsdk.gd index 05f613f1..729d4666 100644 --- a/experimental/godot/addons/playfab_gsdk/gsdk.gd +++ b/experimental/godot/addons/playfab_gsdk/gsdk.gd @@ -41,18 +41,6 @@ func ready_for_players() -> bool: return _internal.current_game_state == PlayFabGsdkTypes.GameState.ACTIVE -## [b]Experimental[/b] — Transitions the game server state to Active.[br] -## Only works when the game server is in StandingBy state. -## Returns [code]true[/code] on success, [code]false[/code] on failure. -func mark_allocated() -> bool: - if _internal.current_game_state != PlayFabGsdkTypes.GameState.STANDING_BY: - push_error("GSDK: Game server is not in the StandingBy state to be marked allocated") - return false - _internal.current_game_state = PlayFabGsdkTypes.GameState.ACTIVE - _internal.transition_to_active.emit() - return true - - ## Logs a message to the GSDK log output. func log_message(message: String) -> void: _internal.start_internal() From 719cf2500a6304acf90a43c4c1b8c77fa488d16c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:37:04 +0000 Subject: [PATCH 05/11] Add experimental GSDKs disclaimer to main README Co-authored-by: dgkanatsios <8256138+dgkanatsios@users.noreply.github.com> --- README.md | 4 ++++ 1 file changed, 4 insertions(+) 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) From ba666c9b238c7a5ad47aad711023fb0d16774c88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 06:16:14 +0000 Subject: [PATCH 06/11] Apply GDScript style guide conventions to Godot GSDK Co-authored-by: dgkanatsios <8256138+dgkanatsios@users.noreply.github.com> --- .../godot/addons/playfab_gsdk/gsdk.gd | 4 ++-- .../addons/playfab_gsdk/internal_gsdk.gd | 20 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/experimental/godot/addons/playfab_gsdk/gsdk.gd b/experimental/godot/addons/playfab_gsdk/gsdk.gd index 729d4666..f17625ed 100644 --- a/experimental/godot/addons/playfab_gsdk/gsdk.gd +++ b/experimental/godot/addons/playfab_gsdk/gsdk.gd @@ -1,10 +1,10 @@ +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. -extends Node var _internal: Node = null @@ -37,7 +37,7 @@ 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.transition_to_active + await _internal.transitioned_to_active return _internal.current_game_state == PlayFabGsdkTypes.GameState.ACTIVE diff --git a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd index 101a5ab1..c004468b 100644 --- a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd +++ b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd @@ -1,12 +1,12 @@ +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. -extends Node ## Emitted when the game server transitions to the Active state. -signal transition_to_active +signal transitioned_to_active var current_game_state: PlayFabGsdkTypes.GameState = PlayFabGsdkTypes.GameState.INVALID var configuration: Dictionary = {} @@ -20,7 +20,7 @@ var cached_scheduled_maintenance_utc: String = "" var debug_logs: bool = false var _started: bool = false -var _logger = preload("gsdk_logger.gd").new() +var _logger := preload("gsdk_logger.gd").new() var _heartbeat_timer: Timer = null var _http_request: HTTPRequest = null var _heartbeat_endpoint: String = "" @@ -87,7 +87,7 @@ func _send_heartbeat() -> void: var players_array: Array = [] for player in connected_players: if player is Dictionary: - players_array.append({"PlayerId": player.get("PlayerId", "")}) + players_array.append({ "PlayerId": player.get("PlayerId", "") }) var heartbeat_request := { "CurrentGameState": PlayFabGsdkTypes.game_state_to_string(current_game_state), @@ -111,7 +111,10 @@ func _send_heartbeat() -> void: _pending_heartbeat = false -func _on_heartbeat_response(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: +func _on_heartbeat_response( + result: int, response_code: int, + _headers: PackedStringArray, body: PackedByteArray +) -> void: _pending_heartbeat = false if result != HTTPRequest.RESULT_SUCCESS: @@ -176,7 +179,7 @@ func _update_state_from_heartbeat(response: Dictionary) -> void: PlayFabGsdkTypes.GameOperation.ACTIVE: if current_game_state != PlayFabGsdkTypes.GameState.ACTIVE: current_game_state = PlayFabGsdkTypes.GameState.ACTIVE - transition_to_active.emit() + transitioned_to_active.emit() _send_heartbeat() PlayFabGsdkTypes.GameOperation.TERMINATE: if current_game_state != PlayFabGsdkTypes.GameState.TERMINATING: @@ -185,7 +188,7 @@ func _update_state_from_heartbeat(response: Dictionary) -> void: if shutdown_callback.is_valid(): shutdown_callback.call() # Unblock ready_for_players() if it is waiting - transition_to_active.emit() + transitioned_to_active.emit() func _load_configuration() -> String: @@ -234,7 +237,8 @@ func _create_config_map() -> Dictionary: 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", "")) - cmap[PlayFabGsdkTypes.SHARED_CONTENT_FOLDER_KEY] = str(configuration.get("sharedContentFolder", "")) + var shared := str(configuration.get("sharedContentFolder", "")) + cmap[PlayFabGsdkTypes.SHARED_CONTENT_FOLDER_KEY] = shared 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") From 9f03c5f9043c7518a99016eef197d59518e3d3a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 06:16:53 +0000 Subject: [PATCH 07/11] Rename generic variable to descriptive shared_content_folder Co-authored-by: dgkanatsios <8256138+dgkanatsios@users.noreply.github.com> --- experimental/godot/addons/playfab_gsdk/internal_gsdk.gd | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd index c004468b..aa509647 100644 --- a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd +++ b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd @@ -237,8 +237,9 @@ func _create_config_map() -> Dictionary: 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 := str(configuration.get("sharedContentFolder", "")) - cmap[PlayFabGsdkTypes.SHARED_CONTENT_FOLDER_KEY] = shared + 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") From 040ea02e255fe1959f87acdd05d03ba81cfcbcb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 06:21:26 +0000 Subject: [PATCH 08/11] Add unit tests for Godot GSDK using GUT framework Co-authored-by: dgkanatsios <8256138+dgkanatsios@users.noreply.github.com> --- experimental/godot/.gutconfig.json | 8 + experimental/godot/README.md | 23 ++ experimental/godot/tests/test_gsdk_logger.gd | 105 +++++++ .../godot/tests/test_internal_gsdk.gd | 291 ++++++++++++++++++ experimental/godot/tests/test_types.gd | 102 ++++++ 5 files changed, 529 insertions(+) create mode 100644 experimental/godot/.gutconfig.json create mode 100644 experimental/godot/tests/test_gsdk_logger.gd create mode 100644 experimental/godot/tests/test_internal_gsdk.gd create mode 100644 experimental/godot/tests/test_types.gd 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 index 8f78e443..3a3d51f0 100644 --- a/experimental/godot/README.md +++ b/experimental/godot/README.md @@ -85,6 +85,29 @@ PlayFabGSDK.update_connected_players([ ]) ``` +## 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 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..f26b3785 --- /dev/null +++ b/experimental/godot/tests/test_internal_gsdk.gd @@ -0,0 +1,291 @@ +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", + "IpV4Address": "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_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..30077860 --- /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, "IpV4Address") + assert_eq(PlayFabGsdkTypes.FQDN_KEY, "fullyQualifiedDomainName") From ac35573046c25e0e22d4f7fc7166da06578325a9 Mon Sep 17 00:00:00 2001 From: Dimitris Ilias Gkanatsios Date: Wed, 8 Apr 2026 17:24:34 +0900 Subject: [PATCH 09/11] Fix 3 bugs in Godot GSDK: IPv4 key, maintenance schedule, gsdkinfo - Fix IpV4Address config key to publicIpV4Address (matches C# SDK) - Add maintenanceSchedule handling with legacy fallback (matches C#/Java) - Add GSDK version reporting POST to /v1/metrics/{id}/gsdkinfo - Update unit tests for all fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../addons/playfab_gsdk/internal_gsdk.gd | 49 ++++++++++++++++--- .../godot/addons/playfab_gsdk/types.gd | 2 +- .../godot/tests/test_internal_gsdk.gd | 42 +++++++++++++++- experimental/godot/tests/test_types.gd | 2 +- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd index aa509647..eb3a60cf 100644 --- a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd +++ b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd @@ -25,6 +25,7 @@ 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 @@ -67,9 +68,32 @@ func start_internal() -> void: _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() @@ -162,13 +186,22 @@ func _update_state_from_heartbeat(response: Dictionary) -> void: for key in metadata: config_map[key] = str(metadata[key]) - # Process scheduled maintenance - if response.has("nextScheduledMaintenanceUtc"): - var 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 scheduled maintenance (new format first, legacy fallback) + var maintenance_time := "" + if response.has("maintenanceSchedule") and response["maintenanceSchedule"] is Dictionary: + var schedule: Dictionary = response["maintenanceSchedule"] + if schedule.has("events") and schedule["events"] is Array: + var events: Array = schedule["events"] + 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"): @@ -244,7 +277,7 @@ func _create_config_map() -> Dictionary: 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("IpV4Address", "")) + 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/types.gd b/experimental/godot/addons/playfab_gsdk/types.gd index cd7a2cc6..13f6acca 100644 --- a/experimental/godot/addons/playfab_gsdk/types.gd +++ b/experimental/godot/addons/playfab_gsdk/types.gd @@ -38,7 +38,7 @@ const TITLE_ID_KEY := "titleId" const BUILD_ID_KEY := "buildId" const REGION_KEY := "region" const VM_ID_KEY := "vmId" -const IPV4_ADDRESS_KEY := "IpV4Address" +const IPV4_ADDRESS_KEY := "publicIpV4Address" const FQDN_KEY := "fullyQualifiedDomainName" diff --git a/experimental/godot/tests/test_internal_gsdk.gd b/experimental/godot/tests/test_internal_gsdk.gd index f26b3785..4d6c2215 100644 --- a/experimental/godot/tests/test_internal_gsdk.gd +++ b/experimental/godot/tests/test_internal_gsdk.gd @@ -29,7 +29,7 @@ func test_create_config_map_extracts_all_fields() -> void: "logFolder": "/tmp/logs", "sharedContentFolder": "/shared", "certificateFolder": "/certs", - "IpV4Address": "10.0.0.1", + "publicIpV4Address": "10.0.0.1", "fullyQualifiedDomainName": "test.playfab.com", "gameCertificates": { "cert1": "/path/to/cert" }, "buildMetadata": { "version": "1.0" }, @@ -249,6 +249,46 @@ func test_heartbeat_processes_maintenance_callback() -> void: ) +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: diff --git a/experimental/godot/tests/test_types.gd b/experimental/godot/tests/test_types.gd index 30077860..1d81f9e6 100644 --- a/experimental/godot/tests/test_types.gd +++ b/experimental/godot/tests/test_types.gd @@ -98,5 +98,5 @@ func test_config_key_constants() -> void: 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, "IpV4Address") + assert_eq(PlayFabGsdkTypes.IPV4_ADDRESS_KEY, "publicIpV4Address") assert_eq(PlayFabGsdkTypes.FQDN_KEY, "fullyQualifiedDomainName") From 025263cc2b625aeced0cb10d66bb6860cfb5fcb5 Mon Sep 17 00:00:00 2001 From: Dimitris Ilias Gkanatsios Date: Wed, 8 Apr 2026 17:51:00 +0900 Subject: [PATCH 10/11] Fix maintenance Events key case and Godot 4.4 dir_exists API - Handle both 'Events' (JsonProperty override) and 'events' (camelCase) in maintenance schedule parsing - Use DirAccess.dir_exists_absolute() for Godot 4.4+ compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- experimental/godot/addons/playfab_gsdk/gsdk_logger.gd | 2 +- experimental/godot/addons/playfab_gsdk/internal_gsdk.gd | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd b/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd index d4b66b9a..f5adb352 100644 --- a/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd +++ b/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd @@ -8,7 +8,7 @@ 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(directory): + if not DirAccess.dir_exists_absolute(directory): DirAccess.make_dir_recursive_absolute(directory) var timestamp := int(Time.get_unix_time_from_system()) diff --git a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd index eb3a60cf..fac880dd 100644 --- a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd +++ b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd @@ -190,8 +190,10 @@ func _update_state_from_heartbeat(response: Dictionary) -> void: var maintenance_time := "" if response.has("maintenanceSchedule") and response["maintenanceSchedule"] is Dictionary: var schedule: Dictionary = response["maintenanceSchedule"] - if schedule.has("events") and schedule["events"] is Array: - var events: Array = schedule["events"] + # 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"): From 5716eec9539a321665e2833aad8de2b77ded7fd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:01:10 +0000 Subject: [PATCH 11/11] Add CI workflow to run Godot GSDK tests on PR changes Agent-Logs-Url: https://github.com/PlayFab/gsdk/sessions/12538c41-b548-404b-9e92-42f68ec6bdc5 Co-authored-by: dgkanatsios <8256138+dgkanatsios@users.noreply.github.com> --- .github/workflows/godot-tests.yml | 40 +++++++++++++++++++++++++++++++ experimental/godot/project.godot | 16 +++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .github/workflows/godot-tests.yml create mode 100644 experimental/godot/project.godot 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/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")