From 4a353255620e6a73fb792524589f0eb14dec1c1b Mon Sep 17 00:00:00 2001 From: Andrew Walker Date: Thu, 21 May 2026 16:34:35 -0500 Subject: [PATCH 1/4] feat(gameye): configurable environments, DI config, multi-port, region routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces hardcoded constants with a GameyeAllocatorConfig singleton registered in ModuleConfig.Setup(), making the allocator configurable without touching implementation code. Adds automatic region selection from Unity QoS MatchProperties["Region"] as a priority-1 path above LocationByPool. Changes: - GameyeAllocatorConfig: DI-registered config class (ImageName, Environment, DefaultLocation, GamePort, Version, AdditionalPorts, LocationByPool, LocationByRegion) - GameyeEnvironment enum: Sandbox / Production — ApiBaseUrl computed from enum, no raw URL strings to typo - Three-tier region resolution: MatchProperties["Region"] → LocationByRegion → PoolName → LocationByPool → DefaultLocation - Version field on SessionRequest with NullValueHandling.Ignore (omitted from JSON when null, preserving "use highest priority tag" default) - AdditionalPorts: extra ports included in AllocationData as port_{name} entries - Tests updated to NUnit/Moq style matching repo conventions; 21 Gameye tests Co-Authored-By: Claude Sonnet 4.6 --- modules/GameyeAllocator/CONFIGURATION.md | 88 +++- .../Project/Client/Models/SessionRequest.cs | 5 +- .../Project/GameyeAllocator.cs | 152 +++++- .../Project/GameyeAllocatorConfig.cs | 109 +++++ tests/AllocatorTests/GameyeAllocatorTests.cs | 451 +++++++++++++----- 5 files changed, 639 insertions(+), 166 deletions(-) create mode 100644 modules/GameyeAllocator/Project/GameyeAllocatorConfig.cs diff --git a/modules/GameyeAllocator/CONFIGURATION.md b/modules/GameyeAllocator/CONFIGURATION.md index 0a7a157..db661ec 100644 --- a/modules/GameyeAllocator/CONFIGURATION.md +++ b/modules/GameyeAllocator/CONFIGURATION.md @@ -14,19 +14,93 @@ Add the following secret in the Unity Dashboard under **Administration > Secrets ## Code Configuration -Update the following constants in `Project/GameyeAllocator.cs`: +All configuration is set in `ModuleConfig.Setup()` in `Project/GameyeAllocator.cs`, where a `GameyeAllocatorConfig` instance is registered with dependency injection. Open that file and edit the values in the config block. -### `ImageName` (line 25) +### `Environment` -Set this to the name of your application image as configured in the Gameye Admin Panel. This must match the image name you registered during setup. +Controls which Gameye API endpoint the allocator targets. -### `DefaultLocation` (line 26) +| Value | API URL | +|---|---| +| `GameyeEnvironment.Sandbox` (default) | `https://api.sandbox-gameye.gameye.net` | +| `GameyeEnvironment.Production` | `https://api-production-gameye.gameye.net` | + +Use `Sandbox` during development and testing. Switch to `Production` before going live. + +### `ImageName` (required) + +The name of your application image as configured in the Gameye Admin Panel. Must match exactly. + +### `DefaultLocation` + +Your preferred default deployment region (e.g. `"europe"`, `"us-east-1"`). Used when the matched pool has no entry in `LocationByPool`. See [available locations](https://www.gameye.com/docs/api-v2/available-locations/) for the full list. Defaults to `"europe"`. + +### `LocationByPool` (optional) + +Maps Unity Matchmaker **pool names** to Gameye location IDs, enabling dynamic region selection per match. When the matched pool is found in this dictionary, that location is sent to Gameye instead of `DefaultLocation`. Pools not in the map fall through to `DefaultLocation`. + +Unity Matchmaker uses pools for region routing — create one pool per region in your queue configuration, then mirror that mapping here. + +```csharp +LocationByPool = new Dictionary +{ + { "eu-west-pool", "eu-west" }, + { "us-central-pool", "us-central" }, + { "ap-ne-pool", "asia-northeast" }, + { "sa-east-pool", "southamerica" }, +}, +``` + +Leave empty (the default) to always use `DefaultLocation`. + +### `GamePort` + +The primary container port your game server listens on (e.g. `7777`). This must match the port exposed in your Dockerfile and configured in the Gameye Admin Panel. This is the port passed to Unity Matchmaker's `AssignmentData.IpPort()`. + +### `AdditionalPorts` (optional) + +A dictionary of additional named ports to include in allocation data. Use this when your game server exposes secondary ports (e.g. a query port, voice port, or RCON port). + +Each entry is returned in `AllocationData` as `port_{name}` so game clients can access them. + +```csharp +AdditionalPorts = new Dictionary +{ + { "query", 27015 }, + { "rcon", 27020 }, +}, +``` + +### `Version` (optional) + +A specific Docker image tag / version to use when starting sessions. When `null` (the default), Gameye uses the highest-priority tag configured in the Admin Panel. -Set this to your preferred default deployment region (e.g. `"europe"`, `"north-america"`). See [available locations](https://www.gameye.com/docs/api-v2/available-locations/) for the full list. +```csharp +Version = "v1.2.3", +``` -### `GamePort` (line 27) +## Example Configuration -Set this to the container port your game server listens on (e.g. `7777`). This must match the port exposed in your Dockerfile and configured in the Gameye Admin Panel. +```csharp +config.Dependencies.AddSingleton(new GameyeAllocatorConfig +{ + ImageName = "my-fps-server", + Environment = GameyeEnvironment.Production, + DefaultLocation = "eu-west", + GamePort = 7777, + Version = "v2.1.0", + LocationByPool = new Dictionary + { + { "eu-west-pool", "eu-west" }, + { "us-central-pool", "us-central" }, + { "ap-ne-pool", "asia-northeast" }, + }, + AdditionalPorts = new Dictionary + { + { "query", 27015 }, + }, +}); +``` ## How It Works diff --git a/modules/GameyeAllocator/Project/Client/Models/SessionRequest.cs b/modules/GameyeAllocator/Project/Client/Models/SessionRequest.cs index 48ca37a..71a39dd 100644 --- a/modules/GameyeAllocator/Project/Client/Models/SessionRequest.cs +++ b/modules/GameyeAllocator/Project/Client/Models/SessionRequest.cs @@ -28,4 +28,7 @@ public class SessionRequest [JsonProperty("ttl")] public int? Ttl { get; set; } -} + + [JsonProperty("version", NullValueHandling = NullValueHandling.Ignore)] + public string? Version { get; set; } +} \ No newline at end of file diff --git a/modules/GameyeAllocator/Project/GameyeAllocator.cs b/modules/GameyeAllocator/Project/GameyeAllocator.cs index 463ab17..845f5ab 100644 --- a/modules/GameyeAllocator/Project/GameyeAllocator.cs +++ b/modules/GameyeAllocator/Project/GameyeAllocator.cs @@ -18,7 +18,8 @@ namespace GameyeAllocatorModule; /// /// Module configuration for dependency injection. -/// Registers IGameApiClient as a singleton for accessing Unity services like Secret Manager. +/// Edit the instance below to configure the allocator +/// for your project — image name, environment, region, ports, and version. /// public class ModuleConfig : ICloudCodeSetup { @@ -26,20 +27,63 @@ public void Setup(ICloudCodeConfig config) { config.Dependencies.AddSingleton(GameApiClient.Create()); config.Dependencies.AddScoped(); + + // ────────────────────────────────────────────────────────────── + // Gameye allocator configuration — edit the values below. + // ────────────────────────────────────────────────────────────── + config.Dependencies.AddSingleton(new GameyeAllocatorConfig + { + // Required — the application image name registered in the Gameye Admin Panel. + ImageName = "test_nginx", + + // The API environment. Use Sandbox for development, Production for live. + Environment = GameyeEnvironment.Sandbox, + + // Default deployment region — used when no pool-to-location mapping matches. + DefaultLocation = "eu-west", + + // Option A — Unity QoS automatic region selection (recommended). + // Maps the value Unity puts in MatchProperties["Region"] to a Gameye location. + // LocationByRegion = new Dictionary + // { + // { "eu-west", "eu-west" }, + // { "us-central", "us-central" }, + // { "asia-northeast", "asia-northeast" }, + // }, + + // Option B — pool-name mapping (use when Unity QoS is not configured). + // LocationByPool = new Dictionary + // { + // { "eu-west-pool", "eu-west" }, + // { "us-central-pool", "us-central" }, + // { "ap-ne-pool", "asia-northeast" }, + // }, + + // Primary game server port (must match your Dockerfile EXPOSE / Admin Panel config). + GamePort = 80, + + // Optional — pin a specific Docker image tag / version. + // When null, Gameye uses the highest-priority tag configured in the Admin Panel. + // Version = "v1.2.3", + + // Optional — additional ports to expose to game clients (e.g. voice, query, RCON). + // These are returned in AllocationData as "port_{name}" alongside the primary port. + // AdditionalPorts = new Dictionary + // { + // { "query", 27015 }, + // { "rcon", 27020 }, + // }, + }); } } -public class GameyeAllocator(IGameApiClient gameApiClient, IGameyeHttpClientFactory httpClientFactory, ILogger logger) : IMatchmakerAllocator +public class GameyeAllocator( + IGameApiClient gameApiClient, + IGameyeHttpClientFactory httpClientFactory, + GameyeAllocatorConfig allocatorConfig, + ILogger logger) : IMatchmakerAllocator { - // Configuration - users should modify these constants for their setup - private const string ImageName = "MyGame"; // TODO: Replace with your Gameye application image name - private const string DefaultLocation = "europe"; // TODO: Replace with your preferred region - private const int GamePort = 7777; // TODO: Replace with your game server port - - // Gameye Constants - private const string GameyeApiUrl = "https://api.gameye.io"; - - // Secret names - these must match the secrets stored in Unity Dashboard + // Secret names — these must match the secrets stored in Unity Dashboard private const string GameyeApiTokenSecretName = "GAMEYE_API_TOKEN"; [CloudCodeFunction("Matchmaker_AllocateServer")] @@ -50,11 +94,14 @@ public async Task Allocate(IExecutionContext context, Allocate Secret gameyeApiToken = await gameApiClient.SecretManager.GetSecret(context, GameyeApiTokenSecretName); using HttpClient client = httpClientFactory.Create(gameyeApiToken.Value); + var resolvedLocation = ResolveLocation(request.MatchmakingResults); + var sessionRequest = new SessionRequest { Id = request.MatchId, - Location = DefaultLocation, - Image = ImageName, + Location = resolvedLocation, + Image = allocatorConfig.ImageName, + Version = allocatorConfig.Version, Env = new Dictionary { { "MATCH_ID", request.MatchId }, @@ -67,7 +114,7 @@ public async Task Allocate(IExecutionContext context, Allocate }; var content = new StringContent(JsonConvert.SerializeObject(sessionRequest), Encoding.UTF8, "application/json"); - HttpResponseMessage response = await client.PostAsync($"{GameyeApiUrl}/session", content); + HttpResponseMessage response = await client.PostAsync($"{allocatorConfig.ApiBaseUrl}/session", content); string responseContent = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) @@ -81,14 +128,27 @@ public async Task Allocate(IExecutionContext context, Allocate var sessionResponse = JsonConvert.DeserializeObject(responseContent); - return new AllocateResponse(AllocateStatus.Created) + var allocationData = new Dictionary { - AllocationData = new Dictionary + { "sessionId", sessionResponse?.Id ?? request.MatchId }, + { "host", sessionResponse?.Host ?? string.Empty }, + { "port", FindPort(sessionResponse?.Ports, allocatorConfig.GamePort) }, + { "location", resolvedLocation }, + }; + + // Include additional named ports so game clients can access them. + foreach (var (name, containerPort) in allocatorConfig.AdditionalPorts) + { + int hostPort = FindPort(sessionResponse?.Ports, containerPort); + if (hostPort > 0) { - { "sessionId", sessionResponse?.Id ?? request.MatchId }, - { "host", sessionResponse?.Host ?? string.Empty }, - { "port", FindGamePort(sessionResponse?.Ports) }, - }, + allocationData[$"port_{name}"] = hostPort; + } + } + + return new AllocateResponse(AllocateStatus.Created) + { + AllocationData = allocationData, }; } catch (Exception e) @@ -128,7 +188,7 @@ public async Task Poll(IExecutionContext context, PollRequest requ { Secret gameyeApiToken = await gameApiClient.SecretManager.GetSecret(context, GameyeApiTokenSecretName); using HttpClient client = httpClientFactory.Create(gameyeApiToken.Value); - HttpResponseMessage response = await client.GetAsync($"{GameyeApiUrl}/session/{sessionId}"); + HttpResponseMessage response = await client.GetAsync($"{allocatorConfig.ApiBaseUrl}/session/{sessionId}"); string responseContent = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) @@ -155,7 +215,7 @@ public async Task Poll(IExecutionContext context, PollRequest requ { AssignmentData = AssignmentData.IpPort( sessionResponse.Host ?? string.Empty, - FindGamePort(sessionResponse.Ports) + FindPort(sessionResponse.Ports, allocatorConfig.GamePort) ), }, "created" or "restarting" => new PollResponse(PollStatus.Pending), @@ -177,15 +237,55 @@ public async Task Poll(IExecutionContext context, PollRequest requ } /// - /// Finds the host port mapped to the configured game port from the session's port mappings. - /// Falls back to the first available port if the configured port is not found. + /// Resolves the Gameye location for this match using a three-tier priority: + /// 1. MatchProperties["Region"] + /// (Unity QoS has already picked the best region — use it directly) + /// 2. PoolName + /// (fallback for studios using per-region pools without QoS) + /// 3. + /// + private string ResolveLocation(MatchmakingResults results) + { + // Priority 1 — Unity QoS resolved region + if (results.MatchProperties.TryGetValue("Region", out var regionObj)) + { + var region = regionObj?.ToString(); + if (!string.IsNullOrEmpty(region) && + allocatorConfig.LocationByRegion.TryGetValue(region, out var regionLocation)) + { + logger.LogInformation("Region resolved via QoS: MatchProperties[Region]={Region} → {Location}", region, regionLocation); + return regionLocation; + } + + if (!string.IsNullOrEmpty(region)) + { + logger.LogWarning("MatchProperties[Region]={Region} has no entry in LocationByRegion — falling through", region); + } + } + + // Priority 2 — pool name mapping + var pool = results.PoolName; + if (!string.IsNullOrEmpty(pool) && + allocatorConfig.LocationByPool.TryGetValue(pool, out var poolLocation)) + { + logger.LogInformation("Region resolved via pool: PoolName={Pool} → {Location}", pool, poolLocation); + return poolLocation; + } + + // Priority 3 — static default + return allocatorConfig.DefaultLocation; + } + + /// + /// Finds the host port mapped to the given container port from the session's port mappings. + /// Falls back to the first available port if the target port is not found. /// - private static int FindGamePort(List? ports) + private static int FindPort(List? ports, int containerPort) { if (ports == null || ports.Count == 0) return 0; - var match = ports.FirstOrDefault(p => p.Container == GamePort); + var match = ports.FirstOrDefault(p => p.Container == containerPort); return match?.Host ?? ports[0].Host; } } diff --git a/modules/GameyeAllocator/Project/GameyeAllocatorConfig.cs b/modules/GameyeAllocator/Project/GameyeAllocatorConfig.cs new file mode 100644 index 0000000..3d63d36 --- /dev/null +++ b/modules/GameyeAllocator/Project/GameyeAllocatorConfig.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; + +namespace GameyeAllocatorModule; + +/// +/// Gameye API environment. Determines which API endpoint the allocator targets. +/// +public enum GameyeEnvironment +{ + Sandbox, + Production, +} + +/// +/// Configuration for the Gameye allocator. Register an instance of this class +/// as a singleton in to control allocator behavior. +/// +public class GameyeAllocatorConfig +{ + /// + /// The Gameye API environment to use. Defaults to . + /// + public GameyeEnvironment Environment { get; set; } = GameyeEnvironment.Sandbox; + + /// + /// The application image name as registered in the Gameye Admin Panel. + /// Must match exactly. + /// + public required string ImageName { get; set; } + + /// + /// The default deployment region (e.g. "europe", "us-east-1"). + /// Used when the matched pool has no entry in . + /// See https://www.gameye.com/docs/api-v2/available-locations/ for the full list. + /// + public string DefaultLocation { get; set; } = "europe"; + + /// + /// Maps Unity QoS region identifiers to Gameye location IDs. + /// When Unity Matchmaker resolves a QoS region for the match, its value arrives in + /// MatchProperties["Region"]. If that value is found here, the corresponding + /// Gameye location is used. This is the preferred approach when Unity QoS is configured — + /// no per-region pools are needed and the region decision is driven by real player latency. + /// + /// The key is whatever Unity puts in MatchProperties["Region"]: a human-readable + /// name (e.g. "eu-west") or a QoS region UUID. + /// + /// + /// LocationByRegion = new Dictionary<string, string> + /// { + /// { "eu-west", "eu-west" }, + /// { "us-central", "us-central" }, + /// { "asia-northeast", "asia-northeast" }, + /// } + /// + /// + /// + public Dictionary LocationByRegion { get; set; } = new(); + + /// + /// Maps Unity Matchmaker pool names to Gameye location IDs. + /// Used when Unity QoS is not configured or MatchProperties["region"] has no entry + /// in . Use this alongside per-region pools in your + /// Matchmaker queue configuration. + /// + /// + /// LocationByPool = new Dictionary<string, string> + /// { + /// { "eu-west-pool", "eu-west" }, + /// { "us-central-pool", "us-central" }, + /// { "ap-ne-pool", "asia-northeast" }, + /// } + /// + /// + /// + public Dictionary LocationByPool { get; set; } = new(); + + /// + /// The primary game server port. This must match the port exposed in your Dockerfile + /// and configured in the Gameye Admin Panel. Used for the Unity Matchmaker + /// assignment. + /// + public int GamePort { get; set; } = 7777; + + /// + /// Optional additional ports to include in allocation data (e.g. query port, RCON port). + /// These are returned in AllocationData as port_{name} entries so + /// game clients can access them alongside the primary port. + /// Key: a descriptive name (e.g. "query", "rcon"). Value: the container port number. + /// + public Dictionary AdditionalPorts { get; set; } = new(); + + /// + /// Optional Docker image tag / version. When set, Gameye starts a session using this + /// specific image version instead of the highest-priority tag. + /// + public string? Version { get; set; } + + /// + /// Returns the base API URL for the configured environment. + /// + public string ApiBaseUrl => Environment switch + { + GameyeEnvironment.Sandbox => "https://api.sandbox-gameye.gameye.net", + GameyeEnvironment.Production => "https://api-production-gameye.gameye.net", + _ => throw new ArgumentOutOfRangeException(nameof(Environment), Environment, "Unknown Gameye environment"), + }; +} diff --git a/tests/AllocatorTests/GameyeAllocatorTests.cs b/tests/AllocatorTests/GameyeAllocatorTests.cs index ce771ab..d6d3698 100644 --- a/tests/AllocatorTests/GameyeAllocatorTests.cs +++ b/tests/AllocatorTests/GameyeAllocatorTests.cs @@ -20,53 +20,82 @@ public class GameyeAllocatorTests private readonly Mock _secretClientMock = new(); private readonly Mock _gameClientMock = new(); private readonly Mock _httpMessageHandlerMock = new(); - private readonly Mock _executionContextMock = new(); - private readonly GameyeAllocator _allocator; + private GameyeAllocator CreateAllocator(GameyeAllocatorConfig? config = null) + { + config ??= new GameyeAllocatorConfig + { + ImageName = "MyGame", + Environment = GameyeEnvironment.Sandbox, + DefaultLocation = "europe", + GamePort = 7777, + }; + return new GameyeAllocator(_gameClientMock.Object, _httpClientFactoryMock.Object, config, _loggerMock.Object); + } - public GameyeAllocatorTests() + [SetUp] + public void SetUp() { + _httpMessageHandlerMock.Reset(); _gameClientMock.SetupGet(g => g.SecretManager).Returns(_secretClientMock.Object); _secretClientMock.Setup(s => s.GetSecret(_executionContextMock.Object, It.IsAny())) .ReturnsAsync(new Secret("secret")); _httpClientFactoryMock.Setup(f => f.Create(It.IsAny())) .Returns(() => new HttpClient(_httpMessageHandlerMock.Object)); - _allocator = new GameyeAllocator(_gameClientMock.Object, _httpClientFactoryMock.Object, _loggerMock.Object); } - [Test] - public async Task TestGameyeCanAllocate() + private void SetupHttpResponse(HttpResponseMessage response) + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + } + + private void SetupHttpResponseWithCapture(HttpResponseMessage response, Action capture) { - _httpMessageHandlerMock.Reset(); _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage() + .Callback((req, _) => capture(req)) + .ReturnsAsync(response); + } + + private static HttpResponseMessage SuccessResponse(string id = "test-session-id", string host = "203.0.113.42", int containerPort = 7777, int hostPort = 49152) => + new() + { + StatusCode = System.Net.HttpStatusCode.Created, + Content = new StringContent(JsonConvert.SerializeObject(new SessionResponse { - StatusCode = System.Net.HttpStatusCode.Created, - Content = new StringContent( - """ - { - "id": "test-session-id", - "host": "203.0.113.42", - "ports": [ - { "type": "udp", "container": 7777, "host": 49152 } - ] - } - """ - ), - }); - - var allocation = await _allocator.Allocate(_executionContextMock.Object, - new AllocateRequest("match-1234", - new MatchmakingResults(null, "matchId", "poolId", "poolName", "queueName", new()))); + Id = id, + Host = host, + Ports = [new PortMapping { Type = "udp", Container = containerPort, Host = hostPort }], + })), + }; + + private static AllocateRequest MakeAllocateRequest( + string matchId = "match-1234", + string poolName = "poolName", + Dictionary? matchProperties = null) => + new(matchId, new MatchmakingResults(null, "matchId", "poolId", poolName, "queueName", + matchProperties ?? new Dictionary())); + + // ── Basic allocation ────────────────────────────────────────────── + + [Test] + public async Task TestGameyeCanAllocate() + { + SetupHttpResponse(SuccessResponse()); + var allocation = await CreateAllocator().Allocate(_executionContextMock.Object, MakeAllocateRequest()); Assert.That(allocation.Status, Is.EqualTo(AllocateStatus.Created)); - Assert.That(allocation.Message, Is.Null); Assert.That(allocation.AllocationData, Is.Not.Null); Assert.That(allocation.AllocationData["sessionId"], Is.EqualTo("test-session-id")); Assert.That(allocation.AllocationData["host"], Is.EqualTo("203.0.113.42")); @@ -77,75 +106,226 @@ public async Task TestGameyeCanAllocate() public async Task TestGameyeCanAllocateWithMatchMetadata() { HttpRequestMessage? capturedRequest = null; - _httpMessageHandlerMock.Reset(); - _httpMessageHandlerMock - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .Callback((req, _) => - { - capturedRequest = req; - }) - .ReturnsAsync(new HttpResponseMessage() - { - StatusCode = System.Net.HttpStatusCode.Created, - Content = new StringContent( - """ - { - "id": "match-1234", - "host": "203.0.113.42", - "ports": [ - { "type": "udp", "container": 7777, "host": 49152 } - ] - } - """ - ), - }); - - var allocation = await _allocator.Allocate(_executionContextMock.Object, - new AllocateRequest("match-1234", - new MatchmakingResults(null, "matchId", "poolId", "testPool", "queueName", new()))); + SetupHttpResponseWithCapture(SuccessResponse(id: "match-1234"), req => capturedRequest = req); - Assert.That(allocation.Status, Is.EqualTo(AllocateStatus.Created)); + var allocation = await CreateAllocator().Allocate(_executionContextMock.Object, + MakeAllocateRequest(matchId: "match-1234", poolName: "testPool")); - // Verify the request body sent to Gameye + Assert.That(allocation.Status, Is.EqualTo(AllocateStatus.Created)); Assert.That(capturedRequest, Is.Not.Null); - Assert.That(capturedRequest!.Content, Is.Not.Null); - var body = await capturedRequest.Content!.ReadAsStringAsync(); - var sessionRequest = JsonConvert.DeserializeObject(body); + var body = JsonConvert.DeserializeObject( + await capturedRequest!.Content!.ReadAsStringAsync()); + + Assert.That(body!.Image, Is.EqualTo("MyGame")); + Assert.That(body.Location, Is.EqualTo("europe")); + Assert.That(body.Id, Is.EqualTo("match-1234")); + Assert.That(body.Env!["MATCH_ID"], Is.EqualTo("match-1234")); + Assert.That(body.Labels!["matchmaker"], Is.EqualTo("unity")); + Assert.That(body.Labels["pool"], Is.EqualTo("testPool")); + } + + // ── Environment URL selection ───────────────────────────────────── + + [Test] + public async Task TestGameyeUsesSandboxUrl() + { + HttpRequestMessage? capturedRequest = null; + SetupHttpResponseWithCapture(SuccessResponse(), req => capturedRequest = req); + + await CreateAllocator(new GameyeAllocatorConfig { ImageName = "g", Environment = GameyeEnvironment.Sandbox }) + .Allocate(_executionContextMock.Object, MakeAllocateRequest()); + + Assert.That(capturedRequest!.RequestUri!.ToString(), + Does.StartWith("https://api.sandbox-gameye.gameye.net/session")); + } + + [Test] + public async Task TestGameyeUsesProductionUrl() + { + HttpRequestMessage? capturedRequest = null; + SetupHttpResponseWithCapture(SuccessResponse(), req => capturedRequest = req); + + await CreateAllocator(new GameyeAllocatorConfig { ImageName = "g", Environment = GameyeEnvironment.Production }) + .Allocate(_executionContextMock.Object, MakeAllocateRequest()); + + Assert.That(capturedRequest!.RequestUri!.ToString(), + Does.StartWith("https://api-production-gameye.gameye.net/session")); + } + + // ── Version field ───────────────────────────────────────────────── + + [Test] + public async Task TestGameyeSendsVersionWhenConfigured() + { + HttpRequestMessage? capturedRequest = null; + SetupHttpResponseWithCapture(SuccessResponse(), req => capturedRequest = req); + + await CreateAllocator(new GameyeAllocatorConfig { ImageName = "g", Version = "v2.1.0" }) + .Allocate(_executionContextMock.Object, MakeAllocateRequest()); + + var body = JsonConvert.DeserializeObject(await capturedRequest!.Content!.ReadAsStringAsync()); + Assert.That(body!.Version, Is.EqualTo("v2.1.0")); + } + + [Test] + public async Task TestGameyeOmitsVersionWhenNull() + { + HttpRequestMessage? capturedRequest = null; + SetupHttpResponseWithCapture(SuccessResponse(), req => capturedRequest = req); + + await CreateAllocator(new GameyeAllocatorConfig { ImageName = "g", Version = null }) + .Allocate(_executionContextMock.Object, MakeAllocateRequest()); + + var rawBody = await capturedRequest!.Content!.ReadAsStringAsync(); + Assert.That(rawBody, Does.Not.Contain("\"version\"")); + } + + // ── Additional ports ────────────────────────────────────────────── + + [Test] + public async Task TestGameyeIncludesAdditionalPorts() + { + SetupHttpResponse(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.Created, + Content = new StringContent(JsonConvert.SerializeObject(new SessionResponse + { + Id = "s", + Host = "1.2.3.4", + Ports = + [ + new PortMapping { Type = "udp", Container = 7777, Host = 25100 }, + new PortMapping { Type = "tcp", Container = 27015, Host = 25101 }, + ], + })), + }); + + var config = new GameyeAllocatorConfig + { + ImageName = "g", + GamePort = 7777, + AdditionalPorts = new Dictionary { { "query", 27015 } }, + }; + + var result = await CreateAllocator(config).Allocate(_executionContextMock.Object, MakeAllocateRequest()); + + Assert.That(result.AllocationData!["port"], Is.EqualTo(25100)); + Assert.That(result.AllocationData["port_query"], Is.EqualTo(25101)); + } + + // ── Region selection ────────────────────────────────────────────── + + [Test] + public async Task TestGameyeUsesPoolLocation() + { + HttpRequestMessage? capturedRequest = null; + SetupHttpResponseWithCapture(SuccessResponse(), req => capturedRequest = req); + + var config = new GameyeAllocatorConfig + { + ImageName = "g", + DefaultLocation = "europe", + LocationByPool = new Dictionary { { "us-central-pool", "us-central" } }, + }; + + await CreateAllocator(config).Allocate(_executionContextMock.Object, + MakeAllocateRequest(poolName: "us-central-pool")); + + var body = JsonConvert.DeserializeObject(await capturedRequest!.Content!.ReadAsStringAsync()); + Assert.That(body!.Location, Is.EqualTo("us-central")); + } + + [Test] + public async Task TestGameyeUsesQosRegion() + { + HttpRequestMessage? capturedRequest = null; + SetupHttpResponseWithCapture(SuccessResponse(), req => capturedRequest = req); + + var config = new GameyeAllocatorConfig + { + ImageName = "g", + DefaultLocation = "europe", + LocationByRegion = new Dictionary { { "us-central", "us-central" } }, + }; + + await CreateAllocator(config).Allocate(_executionContextMock.Object, + MakeAllocateRequest(matchProperties: new Dictionary { { "Region", "us-central" } })); + + var body = JsonConvert.DeserializeObject(await capturedRequest!.Content!.ReadAsStringAsync()); + Assert.That(body!.Location, Is.EqualTo("us-central")); + } + + [Test] + public async Task TestGameyeQosRegionTakesPriorityOverPool() + { + HttpRequestMessage? capturedRequest = null; + SetupHttpResponseWithCapture(SuccessResponse(), req => capturedRequest = req); + + var config = new GameyeAllocatorConfig + { + ImageName = "g", + DefaultLocation = "europe", + LocationByRegion = new Dictionary { { "ap-ne", "asia-northeast" } }, + LocationByPool = new Dictionary { { "eu-west-pool", "eu-west" } }, + }; - Assert.That(sessionRequest, Is.Not.Null); - Assert.That(sessionRequest!.Image, Is.EqualTo("MyGame")); - Assert.That(sessionRequest.Location, Is.EqualTo("europe")); - Assert.That(sessionRequest.Id, Is.EqualTo("match-1234")); - Assert.That(sessionRequest.Env, Is.Not.Null); - Assert.That(sessionRequest.Env!["MATCH_ID"], Is.EqualTo("match-1234")); - Assert.That(sessionRequest.Labels, Is.Not.Null); - Assert.That(sessionRequest.Labels!["matchmaker"], Is.EqualTo("unity")); - Assert.That(sessionRequest.Labels["pool"], Is.EqualTo("testPool")); + await CreateAllocator(config).Allocate(_executionContextMock.Object, + MakeAllocateRequest(poolName: "eu-west-pool", + matchProperties: new Dictionary { { "Region", "ap-ne" } })); + + var body = JsonConvert.DeserializeObject(await capturedRequest!.Content!.ReadAsStringAsync()); + Assert.That(body!.Location, Is.EqualTo("asia-northeast")); } + [Test] + public async Task TestGameyeFallsBackToDefaultLocation() + { + HttpRequestMessage? capturedRequest = null; + SetupHttpResponseWithCapture(SuccessResponse(), req => capturedRequest = req); + + await CreateAllocator(new GameyeAllocatorConfig { ImageName = "g", DefaultLocation = "europe" }) + .Allocate(_executionContextMock.Object, MakeAllocateRequest()); + + var body = JsonConvert.DeserializeObject(await capturedRequest!.Content!.ReadAsStringAsync()); + Assert.That(body!.Location, Is.EqualTo("europe")); + } + + [Test] + public async Task TestGameyeAllocatorUsesDefaultRegionWhenRegionIsEmptyString() + { + HttpRequestMessage? capturedRequest = null; + SetupHttpResponseWithCapture(SuccessResponse(), req => capturedRequest = req); + + var config = new GameyeAllocatorConfig + { + ImageName = "g", + DefaultLocation = "europe", + LocationByRegion = new Dictionary { { "eu-west", "eu-west" } }, + }; + + await CreateAllocator(config).Allocate(_executionContextMock.Object, + MakeAllocateRequest(matchProperties: new Dictionary { { "Region", "" } })); + + var body = JsonConvert.DeserializeObject(await capturedRequest!.Content!.ReadAsStringAsync()); + Assert.That(body!.Location, Is.EqualTo("europe")); + } + + // ── Poll ────────────────────────────────────────────────────────── + [Test] public async Task TestGameyePollReturnsAllocatedFromCachedData() { - // Gameye returns host/port synchronously, so Poll should resolve from AllocationData - var poll = await _allocator.Poll(_executionContextMock.Object, - new PollRequest("match-1234", - new Dictionary - { - { "sessionId", "test-session-id" }, - { "host", "203.0.113.42" }, - { "port", 49152 }, - }, - DateTimeOffset.UtcNow)); + var poll = await CreateAllocator().Poll(_executionContextMock.Object, + new PollRequest("match-1234", new Dictionary + { + { "sessionId", "test-session-id" }, + { "host", "203.0.113.42" }, + { "port", 49152 }, + }, DateTimeOffset.UtcNow)); Assert.That(poll.Status, Is.EqualTo(PollStatus.Allocated)); - Assert.That(poll.Message, Is.Null); - Assert.That(poll.AssignmentData, Is.Not.Null); - Assert.That(poll.AssignmentData.Type, Is.EqualTo(AssignmentType.IpPort)); + Assert.That(poll.AssignmentData!.Type, Is.EqualTo(AssignmentType.IpPort)); Assert.That(poll.AssignmentData.Ip, Is.EqualTo("203.0.113.42")); Assert.That(poll.AssignmentData.Port, Is.EqualTo(49152)); } @@ -153,65 +333,72 @@ public async Task TestGameyePollReturnsAllocatedFromCachedData() [Test] public async Task TestGameyePollFallsBackToApi() { - _httpMessageHandlerMock.Reset(); - _httpMessageHandlerMock - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage + SetupHttpResponse(new HttpResponseMessage + { + Content = new StringContent(JsonConvert.SerializeObject(new SessionResponse + { + Id = "test-session-id", + Host = "203.0.113.42", + Ports = [new PortMapping { Type = "udp", Container = 7777, Host = 49152 }], + Status = "running", + })), + }); + + var poll = await CreateAllocator().Poll(_executionContextMock.Object, + new PollRequest("match-1234", new Dictionary { - Content = new StringContent( - """ - { - "id": "test-session-id", - "host": "203.0.113.42", - "ports": [ - { "type": "udp", "container": 7777, "host": 49152 } - ], - "status": "running" - } - """ - ), - }); - - // Simulate missing host/port in allocation data (edge case) - var poll = await _allocator.Poll(_executionContextMock.Object, - new PollRequest("match-1234", - new Dictionary - { - { "sessionId", "test-session-id" }, - { "host", "" }, - { "port", 0 }, - }, - DateTimeOffset.UtcNow)); + { "sessionId", "test-session-id" }, + { "host", "" }, + { "port", 0 }, + }, DateTimeOffset.UtcNow)); Assert.That(poll.Status, Is.EqualTo(PollStatus.Allocated)); - Assert.That(poll.AssignmentData, Is.Not.Null); - Assert.That(poll.AssignmentData.Ip, Is.EqualTo("203.0.113.42")); + Assert.That(poll.AssignmentData!.Ip, Is.EqualTo("203.0.113.42")); Assert.That(poll.AssignmentData.Port, Is.EqualTo(49152)); } + [TestCase("running", PollStatus.Allocated)] + [TestCase("created", PollStatus.Pending)] + [TestCase("restarting", PollStatus.Pending)] + [TestCase("exited", PollStatus.Error)] + [TestCase("dead", PollStatus.Error)] + [TestCase("unknown-state", PollStatus.Pending)] + public async Task TestGameyePollMapsStatusCorrectly(string gameyeStatus, PollStatus expectedStatus) + { + SetupHttpResponse(new HttpResponseMessage + { + Content = new StringContent(JsonConvert.SerializeObject(new SessionResponse + { + Id = "test-session-id", + Host = "203.0.113.42", + Ports = [new PortMapping { Type = "udp", Container = 7777, Host = 49152 }], + Status = gameyeStatus, + })), + }); + + var poll = await CreateAllocator().Poll(_executionContextMock.Object, + new PollRequest("match-1234", new Dictionary + { + { "sessionId", "test-session-id" }, + { "host", "" }, + { "port", 0 }, + }, DateTimeOffset.UtcNow)); + + Assert.That(poll.Status, Is.EqualTo(expectedStatus)); + } + + // ── Error handling ──────────────────────────────────────────────── + [Test] public async Task TestGameyeAllocationError() { - _httpMessageHandlerMock.Reset(); - _httpMessageHandlerMock - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage() - { - StatusCode = System.Net.HttpStatusCode.NotFound, - Content = new StringContent("Location not found"), - }); + SetupHttpResponse(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.NotFound, + Content = new StringContent("Location not found"), + }); - var allocation = await _allocator.Allocate(_executionContextMock.Object, - new AllocateRequest("match-1234", - new MatchmakingResults(null, "matchId", "poolId", "poolName", "queueName", new()))); + var allocation = await CreateAllocator().Allocate(_executionContextMock.Object, MakeAllocateRequest()); Assert.That(allocation.Status, Is.EqualTo(AllocateStatus.Error)); Assert.That(allocation.Message, Is.Not.Null); From 4a05258a05c223cf117373b50ebff7cff93a8dd9 Mon Sep 17 00:00:00 2001 From: Andrew Walker Date: Tue, 2 Jun 2026 22:05:08 -0500 Subject: [PATCH 2/4] docs(gameye): clarify pool-based region routing is opt-in Address review feedback from @lucy-yuan: pool-to-region mapping is a developer decision (used instead of Unity QoS), not behavior Matchmaker strictly performs. Reword to 'can be configured to use pools'. Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/GameyeAllocator/CONFIGURATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/GameyeAllocator/CONFIGURATION.md b/modules/GameyeAllocator/CONFIGURATION.md index db661ec..2e7d9c8 100644 --- a/modules/GameyeAllocator/CONFIGURATION.md +++ b/modules/GameyeAllocator/CONFIGURATION.md @@ -39,7 +39,7 @@ Your preferred default deployment region (e.g. `"europe"`, `"us-east-1"`). Used Maps Unity Matchmaker **pool names** to Gameye location IDs, enabling dynamic region selection per match. When the matched pool is found in this dictionary, that location is sent to Gameye instead of `DefaultLocation`. Pools not in the map fall through to `DefaultLocation`. -Unity Matchmaker uses pools for region routing — create one pool per region in your queue configuration, then mirror that mapping here. +Unity Matchmaker can be configured to use pools for region routing — create one pool per region in your queue configuration, then mirror that mapping here. ```csharp LocationByPool = new Dictionary From 8704568326f9d3843e1100a4287173090211d45e Mon Sep 17 00:00:00 2001 From: Andrew Walker Date: Wed, 3 Jun 2026 07:43:18 -0500 Subject: [PATCH 3/4] feat(gameye): warn on every allocation while running in Sandbox Environment defaults to Sandbox, which is easy to leave unset when going live. Rather than silently routing production matchmaking traffic to sandbox infrastructure, the allocator now logs the active environment on every allocation: a WARNING in Sandbox (with a reminder to set Production) and an INFO confirmation in Production. - GameyeAllocator.Allocate: log active environment + ApiBaseUrl - CONFIGURATION.md: callout that Environment defaults to Sandbox and the allocator warns until switched to Production - Tests: assert the Sandbox warning fires and Production logs info with no warning; clear logger invocations per test (shared NUnit fixture) Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/GameyeAllocator/CONFIGURATION.md | 2 + .../Project/GameyeAllocator.cs | 12 +++++ tests/AllocatorTests/GameyeAllocatorTests.cs | 50 +++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/modules/GameyeAllocator/CONFIGURATION.md b/modules/GameyeAllocator/CONFIGURATION.md index 2e7d9c8..4989588 100644 --- a/modules/GameyeAllocator/CONFIGURATION.md +++ b/modules/GameyeAllocator/CONFIGURATION.md @@ -27,6 +27,8 @@ Controls which Gameye API endpoint the allocator targets. Use `Sandbox` during development and testing. Switch to `Production` before going live. +> ⚠️ **`Environment` defaults to `Sandbox`.** If you go live without setting `Environment = GameyeEnvironment.Production`, every allocation silently targets sandbox infrastructure. As a safeguard, the allocator logs a **warning on every allocation while running in Sandbox** (and an info line confirming `PRODUCTION` once switched) — watch your Cloud Code logs to confirm the environment before launch. + ### `ImageName` (required) The name of your application image as configured in the Gameye Admin Panel. Must match exactly. diff --git a/modules/GameyeAllocator/Project/GameyeAllocator.cs b/modules/GameyeAllocator/Project/GameyeAllocator.cs index 845f5ab..c6d3700 100644 --- a/modules/GameyeAllocator/Project/GameyeAllocator.cs +++ b/modules/GameyeAllocator/Project/GameyeAllocator.cs @@ -91,6 +91,18 @@ public async Task Allocate(IExecutionContext context, Allocate { try { + // Surface which Gameye environment this allocation targets. Environment defaults to + // Sandbox, so a production deployment that forgot to set it will emit a warning on + // every allocation rather than silently routing live traffic to sandbox infrastructure. + if (allocatorConfig.Environment == GameyeEnvironment.Sandbox) + { + logger.LogWarning("GameyeAllocator is running in SANDBOX ({ApiBaseUrl}) — sandbox infrastructure is not intended for production traffic. Set Environment = GameyeEnvironment.Production in ModuleConfig.Setup() before going live.", allocatorConfig.ApiBaseUrl); + } + else + { + logger.LogInformation("GameyeAllocator is running in PRODUCTION ({ApiBaseUrl}).", allocatorConfig.ApiBaseUrl); + } + Secret gameyeApiToken = await gameApiClient.SecretManager.GetSecret(context, GameyeApiTokenSecretName); using HttpClient client = httpClientFactory.Create(gameyeApiToken.Value); diff --git a/tests/AllocatorTests/GameyeAllocatorTests.cs b/tests/AllocatorTests/GameyeAllocatorTests.cs index d6d3698..453e3d7 100644 --- a/tests/AllocatorTests/GameyeAllocatorTests.cs +++ b/tests/AllocatorTests/GameyeAllocatorTests.cs @@ -38,6 +38,9 @@ private GameyeAllocator CreateAllocator(GameyeAllocatorConfig? config = null) public void SetUp() { _httpMessageHandlerMock.Reset(); + // NUnit reuses one fixture instance across tests, so clear accumulated logger + // invocations to keep per-test Verify(... Times.Once/Never) assertions isolated. + _loggerMock.Invocations.Clear(); _gameClientMock.SetupGet(g => g.SecretManager).Returns(_secretClientMock.Object); _secretClientMock.Setup(s => s.GetSecret(_executionContextMock.Object, It.IsAny())) .ReturnsAsync(new Secret("secret")); @@ -153,6 +156,53 @@ await CreateAllocator(new GameyeAllocatorConfig { ImageName = "g", Environment = Does.StartWith("https://api-production-gameye.gameye.net/session")); } + // ── Environment logging ────────────────────────────────────────── + + [Test] + public async Task TestGameyeLogsWarningWhenRunningInSandbox() + { + SetupHttpResponse(SuccessResponse()); + + await CreateAllocator(new GameyeAllocatorConfig { ImageName = "g", Environment = GameyeEnvironment.Sandbox }) + .Allocate(_executionContextMock.Object, MakeAllocateRequest()); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains("SANDBOX")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Test] + public async Task TestGameyeLogsInfoAndNoWarningWhenRunningInProduction() + { + SetupHttpResponse(SuccessResponse()); + + await CreateAllocator(new GameyeAllocatorConfig { ImageName = "g", Environment = GameyeEnvironment.Production }) + .Allocate(_executionContextMock.Object, MakeAllocateRequest()); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains("PRODUCTION")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + // ── Version field ───────────────────────────────────────────────── [Test] From 2bcd3c1bb0d720bb6a6c41669582136abec633bb Mon Sep 17 00:00:00 2001 From: Andrew Walker Date: Fri, 5 Jun 2026 14:01:37 -0500 Subject: [PATCH 4/4] docs(gameye): use clear image placeholder --- modules/GameyeAllocator/Project/GameyeAllocator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/GameyeAllocator/Project/GameyeAllocator.cs b/modules/GameyeAllocator/Project/GameyeAllocator.cs index c6d3700..22bef46 100644 --- a/modules/GameyeAllocator/Project/GameyeAllocator.cs +++ b/modules/GameyeAllocator/Project/GameyeAllocator.cs @@ -34,7 +34,7 @@ public void Setup(ICloudCodeConfig config) config.Dependencies.AddSingleton(new GameyeAllocatorConfig { // Required — the application image name registered in the Gameye Admin Panel. - ImageName = "test_nginx", + ImageName = "your-image-name", // The API environment. Use Sandbox for development, Production for live. Environment = GameyeEnvironment.Sandbox,