diff --git a/DorkNet.Server.Tests/AdminInstancePullTests.cs b/DorkNet.Server.Tests/AdminInstancePullTests.cs
new file mode 100644
index 0000000..d23f174
--- /dev/null
+++ b/DorkNet.Server.Tests/AdminInstancePullTests.cs
@@ -0,0 +1,154 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using DorkNet.Server.Controllers.Match;
+using DorkNet.Server.Data;
+using DorkNet.Server.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Regression: admin "pull a player into an instance" broke because the
+/// instance id (an int64 well past JS's 2^53 safe-integer range) was
+/// emitted as a JSON number. The admin SPA's JSON.parse rounded it to the
+/// nearest double, so the pull URL targeted a non-existent instance and
+/// the server returned instance_not_active. The fix serializes the
+/// id as a STRING so the exact digits survive the browser round-trip.
+///
+public sealed class AdminInstancePullTests : IClassFixture
+{
+ // A dorm-style instance id past 2^53 — the value class that a JS
+ // Number cannot represent exactly.
+ private const long BigInstanceId = 7597291079085522944L;
+
+ private readonly DorkNetServerFactory _factory;
+
+ public AdminInstancePullTests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Instances_list_emits_id_as_string_and_pull_with_exact_id_succeeds()
+ {
+ using var setup = ApiClient();
+ var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ long roomId;
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // Promote the caller to admin (AdminOnly reads IsAdmin from DB).
+ var adminRow = await db.Players.FirstAsync(p => p.Id == admin.PlayerId);
+ adminRow.IsAdmin = true;
+
+ roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
+ await db.SaveChangesAsync();
+
+ // Put the streamer in a live instance carrying the huge id.
+ var presence = scope.ServiceProvider.GetRequiredService();
+ presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
+ {
+ RoomInstanceId = BigInstanceId,
+ RoomId = roomId,
+ SubRoomId = 0,
+ PhotonRoomId = "^teststreamerinstance",
+ PhotonRegionId = "us",
+ MaxCapacity = 8,
+ });
+ presence.MarkActive(streamer.PlayerId);
+ }
+
+ using var adminClient = ApiClient(admin);
+ adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
+
+ // The list must emit roomInstanceId as a STRING equal to the exact
+ // digits — never a number (which would already be rounded here).
+ var list = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/instances");
+ var row = list.EnumerateArray()
+ .First(r => r.GetProperty("roomInstanceId").GetString() == BigInstanceId.ToString());
+ Assert.Equal(JsonValueKind.String, row.GetProperty("roomInstanceId").ValueKind);
+
+ // Pull the second player in using the EXACT id from the list. This
+ // is what the SPA does; with the string round-trip the digits are
+ // intact and the instance matches (pre-fix: 404 instance_not_active).
+ var idFromList = row.GetProperty("roomInstanceId").GetString()!;
+ using var pull = await adminClient.PostAsync(
+ $"/api/admin/v1/rooms/{roomId}/instances/{idFromList}/pull",
+ new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
+ Encoding.UTF8, "application/json"));
+
+ Assert.Equal(HttpStatusCode.OK, pull.StatusCode);
+ using var body = await JsonDocument.ParseAsync(await pull.Content.ReadAsStreamAsync());
+ Assert.True(body.RootElement.GetProperty("ok").GetBoolean());
+ // The pulled player now shares the streamer's instance.
+ await using var verify = _factory.Services.CreateAsyncScope();
+ var presenceCheck = verify.ServiceProvider.GetRequiredService();
+ Assert.Equal(BigInstanceId, presenceCheck.GetRoom(pulled.PlayerId)!.RoomInstanceId);
+ }
+
+ [Fact]
+ public async Task Pull_with_precision_mangled_id_is_rejected()
+ {
+ using var setup = ApiClient();
+ var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ long roomId;
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
+ roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
+ await db.SaveChangesAsync();
+ var presence = scope.ServiceProvider.GetRequiredService();
+ presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
+ {
+ RoomInstanceId = BigInstanceId,
+ RoomId = roomId,
+ PhotonRoomId = "^teststreamerinstance2",
+ PhotonRegionId = "us",
+ MaxCapacity = 8,
+ });
+ presence.MarkActive(streamer.PlayerId);
+ }
+
+ using var adminClient = ApiClient(admin);
+ adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
+
+ // The value the admin SPA's JSON.parse actually produced for this
+ // id (from the bug report log) — the exact failure mode.
+ const long mangled = 7597291079085523000L;
+ Assert.NotEqual(BigInstanceId, mangled);
+ using var pull = await adminClient.PostAsync(
+ $"/api/admin/v1/rooms/{roomId}/instances/{mangled}/pull",
+ new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
+ Encoding.UTF8, "application/json"));
+ Assert.Equal(HttpStatusCode.NotFound, pull.StatusCode);
+ }
+
+ private HttpClient ApiClient(GameClientSession? session = null)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+
+ private static async Task GetJsonAsync(HttpClient client, string path)
+ {
+ using var response = await client.GetAsync(path);
+ response.EnsureSuccessStatusCode();
+ using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
+ return document.RootElement.Clone();
+ }
+}
diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs
index bb2e97f..f4f8637 100644
--- a/DorkNet.Server/Controllers/Admin/AdminController.cs
+++ b/DorkNet.Server/Controllers/Admin/AdminController.cs
@@ -1285,7 +1285,13 @@ public class AdminController(
master = i.pids.OrderBy(p => p).FirstOrDefault();
return new
{
- roomInstanceId = i.roomInstanceId,
+ // Instance ids are int64 values well past JS's safe-integer
+ // range (2^53); if emitted as a JSON number the admin SPA's
+ // JSON.parse rounds them to the nearest double and the pull /
+ // close URLs then target a non-existent instance
+ // ("instance_not_active"). Emit as a STRING so the browser
+ // round-trips the exact digits.
+ roomInstanceId = i.roomInstanceId.ToString(),
roomId = i.roomId,
subRoomId = i.subRoomId,
roomName = i.roomName,
@@ -1341,7 +1347,7 @@ public class AdminController(
sample);
await LogAsync("instance_pull", "instance", instanceId, $"player={body.PlayerId} room={id}");
await db.SaveChangesAsync();
- return Ok(new { ok = true, instanceId, roomInstanceId = sample.RoomInstanceId });
+ return Ok(new { ok = true, instanceId = instanceId.ToString(), roomInstanceId = sample.RoomInstanceId.ToString() });
}
/// POST api/admin/v1/rooms/{id}/instances/{instanceId}/close
@@ -2042,7 +2048,9 @@ public class AdminController(
room.RoomId,
Name = roomName ?? room.Name,
room.SubRoomId,
- room.RoomInstanceId,
+ // String — int64 past JS's 2^53 safe range (see the
+ // /instances comment).
+ RoomInstanceId = room.RoomInstanceId.ToString(),
room.PhotonRoomId,
room.PhotonRegionId,
room.IsPrivate,
@@ -2887,7 +2895,7 @@ public class AdminController(
{
g = new InstanceGroup
{
- RoomInstanceId = room.RoomInstanceId,
+ RoomInstanceId = room.RoomInstanceId.ToString(),
RoomId = room.RoomId,
SubRoomId = room.SubRoomId,
RoomName = roomNameMap.GetValueOrDefault(room.RoomId, $"#{room.RoomId}"),
@@ -2916,7 +2924,10 @@ public class AdminController(
private sealed class InstanceGroup
{
- public long RoomInstanceId { get; set; }
+ /// String, not long — see the /instances comment: the
+ /// JS admin SPA mangles int64s above 2^53 unless they're
+ /// serialized as strings.
+ public string RoomInstanceId { get; set; } = "";
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public string RoomName { get; set; } = "";
@@ -2948,7 +2959,11 @@ public class AdminController(
public sealed record ForceJoinRequest(
long RoomId,
long? SubRoomId,
- long? RoomInstanceId,
+ // String, not long?: the "send into this instance" flow echoes the
+ // roomInstanceId the admin SPA received (now a string, to survive
+ // JSON.parse of >2^53 ids). Parsed to long below; blank/omitted =
+ // mint a fresh default instance.
+ string? RoomInstanceId,
string? PhotonRoomId,
string? PhotonRegionId);
@@ -2988,8 +3003,9 @@ public class AdminController(
var photonRoomId = !string.IsNullOrEmpty(body.PhotonRoomId)
? body.PhotonRoomId
: $"^{room.Name.ToLowerInvariant()}_{room.Id}";
- var roomInstanceId = body.RoomInstanceId
- ?? (room.Id * 100_000L + subRoomId);
+ var roomInstanceId = long.TryParse(body.RoomInstanceId, out var parsedInstanceId)
+ ? parsedInstanceId
+ : (room.Id * 100_000L + subRoomId);
var photonRegionId = !string.IsNullOrEmpty(body.PhotonRegionId)
? body.PhotonRegionId
: "us";
@@ -3036,7 +3052,7 @@ public class AdminController(
id,
roomId = body.RoomId,
subRoomId,
- roomInstanceId,
+ roomInstanceId = roomInstanceId.ToString(),
photonRoomId,
});
}
diff --git a/DorkNet.Server/admin-ui/src/lib/types.ts b/DorkNet.Server/admin-ui/src/lib/types.ts
index 2c13005..f61adc8 100644
--- a/DorkNet.Server/admin-ui/src/lib/types.ts
+++ b/DorkNet.Server/admin-ui/src/lib/types.ts
@@ -113,7 +113,10 @@ export interface RoomRoleGrant {
// else lowest-pid). The matching participant has isMaster=true so the
// UI can tag a row without recomputing.
export interface RoomInstance {
- roomInstanceId: number;
+ // String, not number: instance ids are int64s past JS's 2^53 safe
+ // range, so the server sends them as strings to survive JSON.parse.
+ // Used verbatim in pull/close URLs — never coerce to Number.
+ roomInstanceId: string;
roomId: number;
subRoomId: number;
roomName: string;
@@ -229,7 +232,7 @@ export interface Stats {
roomId: number;
name: string;
subRoomId: number;
- roomInstanceId: number;
+ roomInstanceId: string;
photonRoomId: string;
photonRegionId: string;
isPrivate: boolean;
@@ -287,7 +290,9 @@ export interface StorefrontDefinition {
}
export interface Instance {
- roomInstanceId: number;
+ // String, not number — int64 past JS's 2^53 safe range (server sends
+ // it as a string so JSON.parse doesn't round it).
+ roomInstanceId: string;
roomId: number;
subRoomId: number;
roomName: string;