From f43ce3f368e0a3666cc6463988e315049d9f254d Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:06:49 +0300
Subject: [PATCH] feat: per-sub-room max players, editable in game and from
admin
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Player caps were two settings pretending to be one. RoomEntity.MaxCapacity is
what the ROOM advertises (and what matchmaking hands to
RoomInstance.MaxCapacity); RoomSceneEntity.MaxPlayers is what ONE sub-room caps
itself at. The details payload was reading its room-level MaxPlayers off
sub-room 0, which collapsed the two — editing a sub-room would have moved the
room's advertised capacity with it. It now reports MaxCapacity, and each entry
in SubRooms[] reports its own figure.
The in-game path already existed (the "Max Player Count in This Subroom"
slider). Admin had no way to reach it at all — only zip import ever set it — so
a cap could not be corrected on a room the admin does not own:
GET api/admin/v1/rooms/{id}/subrooms
PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers {"MaxPlayers":n}
subRoomId is the sub-room's ORDER INDEX — the id the client and the rest of the
wire use — not the row's primary key. Row ids are serialised as strings because
the admin SPA is JavaScript and mangles integers past 2^53. Both paths clamp to
1..80, matching rooms/{id}/props: 0 would make a sub-room unjoinable, and a
shared range stops the two settings disagreeing about what is legal.
The test drives the client's exact wire rather than a convenient one:
NLDBPDCNNCF.MNBPCGJNLNP builds a BLNOGFGHIIF whose single JSON key is
"maxPlayers" and PUTs it — this route does NOT form-encode like most of its
siblings, so a form-bodied test would have passed while the real client's body
went unread. It also asserts the reply carries the new value, since the client
deserialises it back as FGCPNAACHIK.
---
.../SubRoomMaxPlayersTests.cs | 164 ++++++++++++++++++
.../API/Rooms/V2/RoomsController.cs | 9 +-
.../Controllers/Admin/AdminController.cs | 75 ++++++++
docs/admin.md | 40 +++++
4 files changed, 285 insertions(+), 3 deletions(-)
create mode 100644 DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs
diff --git a/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs b/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs
new file mode 100644
index 0000000..e801474
--- /dev/null
+++ b/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs
@@ -0,0 +1,164 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Max players is a PER SUB-ROOM setting, adjustable from two places:
+///
+/// in-game PUT rooms/{id}/subrooms/{sub}/maxplayers (owner only — the
+/// "Max Player Count in This Subroom" slider)
+/// admin PUT api/admin/v1/rooms/{id}/subrooms/{sub}/maxplayers
+///
+/// Both write the same , so a change
+/// made in one place has to be visible from the other. That is the property
+/// worth pinning: two independent write paths onto one column drift silently,
+/// and the symptom (a slider that snaps back) looks like a client bug.
+///
+/// Kept distinct from the room's own ,
+/// which is what the ROOM advertises and what matchmaking hands to
+/// RoomInstance.MaxCapacity. Reading the room-level figure off sub-room 0
+/// would collapse the two settings into one.
+///
+public sealed class SubRoomMaxPlayersTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public SubRoomMaxPlayersTests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Owner_and_admin_edit_the_same_per_subroom_value()
+ {
+ using var setup = Client("rooms");
+ var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ var roomId = 9_700_000 + Random.Shared.Next(1, 99_999);
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
+
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"Caps{Guid.NewGuid():N}"[..18],
+ CreatorPlayerId = owner.PlayerId,
+ MaxCapacity = 12,
+ });
+ db.RoomScenes.Add(new RoomSceneEntity
+ { RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
+ db.RoomScenes.Add(new RoomSceneEntity
+ { RoomId = roomId, Name = "Arena", OrderIndex = 1, MaxPlayers = 8 });
+ await db.SaveChangesAsync();
+ }
+
+ // ── in-game: the owner's slider on sub-room 1 only ──────────────────
+ // Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
+ // Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
+ // "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
+ // most of its siblings use. Testing with a form would pass while the
+ // real client's body went unread.
+ using var ownerClient = Client("rooms", owner);
+ using var slider = await ownerClient.PutAsJsonAsync(
+ $"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
+ var sliderBody = await slider.Content.ReadAsStringAsync();
+ Assert.True(slider.IsSuccessStatusCode,
+ $"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
+
+ // The client deserialises the reply as FGCPNAACHIK — the room-details
+ // shape — so the slider's own response has to carry the new value.
+ var echoed = JsonDocument.Parse(sliderBody).RootElement
+ .GetProperty("SubRooms").EnumerateArray()
+ .First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
+ Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
+
+ // ── admin: reads what the owner just set, then edits sub-room 0 ─────
+ using var adminClient = Client("admin", admin);
+ var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
+ var subRooms = listed.EnumerateArray()
+ .ToDictionary(e => e.GetProperty("subRoomId").GetInt32(),
+ e => e.GetProperty("maxPlayers").GetInt32());
+
+ Assert.Equal(24, subRooms[1]);
+ Assert.Equal(8, subRooms[0]);
+
+ using var adminSet = await adminClient.PutAsJsonAsync(
+ $"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 40 });
+ Assert.True(adminSet.IsSuccessStatusCode,
+ $"admin PUT maxplayers -> {(int)adminSet.StatusCode}: {await adminSet.Content.ReadAsStringAsync()}");
+
+ // ── the room's own details carry each sub-room's own figure ─────────
+ var details = await GetJsonAsync(ownerClient, $"/rooms/{roomId}");
+ var wire = details.GetProperty("SubRooms").EnumerateArray()
+ .ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
+ e => e.GetProperty("MaxPlayers").GetInt32());
+
+ Assert.Equal(40, wire[0]);
+ Assert.Equal(24, wire[1]);
+
+ // The room-level figure is the room's advertised capacity, NOT a copy
+ // of sub-room 0 — otherwise the admin edit above would have moved it.
+ Assert.Equal(12, details.GetProperty("MaxPlayers").GetInt32());
+ }
+
+ [Fact]
+ public async Task Admin_cap_is_clamped_to_a_joinable_range()
+ {
+ using var setup = Client("rooms");
+ var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ var roomId = 9_800_000 + Random.Shared.Next(1, 99_999);
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"Clamp{Guid.NewGuid():N}"[..18],
+ CreatorPlayerId = owner.PlayerId,
+ });
+ db.RoomScenes.Add(new RoomSceneEntity
+ { RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
+ await db.SaveChangesAsync();
+ }
+
+ using var adminClient = Client("admin", admin);
+
+ // 0 would make the sub-room unjoinable.
+ using var tooLow = await adminClient.PutAsJsonAsync(
+ $"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 0 });
+ var low = JsonDocument.Parse(await tooLow.Content.ReadAsStringAsync()).RootElement;
+ Assert.Equal(1, low.GetProperty("maxPlayers").GetInt32());
+
+ using var tooHigh = await adminClient.PutAsJsonAsync(
+ $"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 9999 });
+ var high = JsonDocument.Parse(await tooHigh.Content.ReadAsStringAsync()).RootElement;
+ Assert.Equal(80, high.GetProperty("maxPlayers").GetInt32());
+ }
+
+ private static async Task GetJsonAsync(HttpClient client, string path)
+ {
+ using var response = await client.GetAsync(path);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
+ return JsonDocument.Parse(body).RootElement.Clone();
+ }
+
+ private HttpClient Client(string host, GameClientSession? session = null)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+}
diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
index 1ddac0c..222e6b4 100644
--- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
+++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
@@ -2143,9 +2143,12 @@ public class RoomsController(
// name-keyed reader, presence does.
DataBlob = dataBlobName,
DataBlobHash = (string?)null,
- // Room-level cap mirrors sub-room 0, which is where the client
- // reads it from for a single-sub-room room.
- MaxPlayers = sceneRows is { Count: > 0 } ? sceneRows[0].MaxPlayers : 8,
+ // The room-level cap is the room's own advertised capacity, which is
+ // what RoomEntity.MaxCapacity exists for and what matchmaking hands
+ // to RoomInstance.MaxCapacity. Each sub-room carries its own
+ // MaxPlayers in the SubRooms array — they are separate settings, so
+ // this must not be read off sub-room 0.
+ MaxPlayers = room.MaxCapacity,
// ToxMod is Rec Room's hosted voice moderation. There is no such
// service here, so it is off — the client only uses this to decide
// whether to show the indicator.
diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs
index 59502d2..40a8195 100644
--- a/DorkNet.Server/Controllers/Admin/AdminController.cs
+++ b/DorkNet.Server/Controllers/Admin/AdminController.cs
@@ -1097,6 +1097,81 @@ public class AdminController(
});
}
+ /// GET api/admin/v1/rooms/{id}/subrooms — every sub-room of
+ /// a room with the settings that are adjustable per sub-room.
+ ///
+ /// Max players is genuinely per sub-room: the room's own
+ /// is what the room advertises and what
+ /// matchmaking hands to RoomInstance.MaxCapacity, while each sub-room caps
+ /// itself through . In-game the
+ /// owner drives this from the "Max Player Count in This Subroom" slider
+ /// (PUT rooms/{id}/subrooms/{sub}/maxplayers); this is the same setting,
+ /// reachable for rooms an admin does not own.
+ [HttpGet("rooms/{id:long}/subrooms")]
+ public async Task GetRoomSubRooms(long id)
+ {
+ if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound();
+
+ var scenes = await db.RoomScenes
+ .Where(s => s.RoomId == id)
+ .OrderBy(s => s.OrderIndex)
+ .ToListAsync();
+
+ return Ok(scenes.Select(s => new
+ {
+ // Ids go out as strings: the admin SPA is JavaScript and silently
+ // mangles integers past 2^53.
+ id = s.Id.ToString(),
+ subRoomId = s.OrderIndex,
+ name = s.Name,
+ maxPlayers = s.MaxPlayers,
+ canMatchmakeInto = s.CanMatchmakeInto,
+ isSandbox = s.IsSandbox,
+ dataBlobName = s.DataBlobName,
+ dataModifiedAt = s.DataModifiedAt,
+ }).ToList());
+ }
+
+ public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers);
+
+ /// PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers
+ /// — set one sub-room's player cap, addressed by its ORDER INDEX, which is
+ /// the sub-room id the client and the rest of the wire use (the row's own
+ /// primary key is an internal detail).
+ ///
+ /// Clamped to the same 1..80 range as the room-level capacity: 0 would make
+ /// a sub-room unjoinable, and the ceiling matches what the room props
+ /// endpoint already enforces so the two settings can't disagree about what
+ /// is a legal value.
+ [HttpPut("rooms/{id:long}/subrooms/{subRoomId:int}/maxplayers")]
+ [HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/maxplayers")]
+ public async Task SetSubRoomMaxPlayers(
+ long id, int subRoomId, [FromBody] SetSubRoomMaxPlayersRequest body)
+ {
+ var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
+ if (room is null) return NotFound();
+
+ var scene = await db.RoomScenes
+ .FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
+ if (scene is null) return NotFound();
+
+ var previous = scene.MaxPlayers;
+ scene.MaxPlayers = Math.Clamp(body.MaxPlayers, 1, 80);
+ scene.DataModifiedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+
+ await LogAsync("update_subroom_maxplayers", "room", id,
+ $"subRoom={subRoomId} maxPlayers {previous} -> {scene.MaxPlayers}");
+
+ return Ok(new
+ {
+ id = scene.Id.ToString(),
+ subRoomId = scene.OrderIndex,
+ name = scene.Name,
+ maxPlayers = scene.MaxPlayers,
+ });
+ }
+
public sealed record AddRoleRequest(long PlayerId, int Role, bool Accepted = true);
/// POST api/admin/v1/rooms/{id}/roles — grant a
diff --git a/docs/admin.md b/docs/admin.md
index 96cadbb..22fc381 100644
--- a/docs/admin.md
+++ b/docs/admin.md
@@ -139,6 +139,46 @@ in S3 is modified. Toggle endpoint:
`{"Disabled": bool}`. Clamped responses are served with a 60s edge TTL so
flipping the toggle lands within a minute.
+## Per-sub-room max players (`/rooms` → room detail)
+
+Player caps are two separate settings, and conflating them is the easy
+mistake here:
+
+| Setting | Column | Scope |
+| --- | --- | --- |
+| `MaxCapacity` | `RoomEntity.MaxCapacity` | What the ROOM advertises; flows into `RoomInstance.MaxCapacity` for matchmaking and into the room-level `MaxPlayers` of the details payload. |
+| `MaxPlayers` | `RoomSceneEntity.MaxPlayers` | What ONE sub-room caps itself at; appears per entry in the details `SubRooms[]`. |
+
+The room-level figure must not be read off sub-room 0 — that collapses the
+two into one and makes an admin edit to a sub-room silently move the
+room's advertised capacity.
+
+Sub-room caps have two write paths onto the same column:
+
+- **In game** (room owner only) — the "Max Player Count in This Subroom"
+ slider issues `PUT rooms/{id}/subrooms/{sub}/maxplayers`. Unlike most of
+ its siblings this one does **not** form-encode: `NLDBPDCNNCF.MNBPCGJNLNP`
+ (`Int64, Int64, Int32`) builds a `BLNOGFGHIIF` whose single JSON key is
+ `maxPlayers`, with verb 3 = PUT. It reads the reply back as
+ `FGCPNAACHIK`, so the response is the full room-details shape and has to
+ carry the new value.
+- **Admin** (any room, ownership not required):
+ - `GET api/admin/v1/rooms/{id}/subrooms` — every sub-room with its cap,
+ matchmaking flag, sandbox flag and current data blob. Row ids go out as
+ **strings**; the SPA is JavaScript and mangles integers past 2^53.
+ - `PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers` with
+ `{"MaxPlayers": int}`. `subRoomId` is the sub-room's **order index** —
+ the id the client and the rest of the wire use — not the row's primary
+ key.
+
+Both paths clamp to 1..80, matching the room-level cap in
+`rooms/{id}/props`: 0 would make a sub-room unjoinable, and a shared range
+keeps the two settings from disagreeing about what is a legal value.
+
+Enforcement caveat carried over from `RoomEntity.MaxCapacity`: this is the
+advertised cap. The client never sets Photon `RoomOptions.MaxPlayers`, so
+hard enforcement still needs a ClientMod Photon patch.
+
## Build And Deploy
The service Dockerfile builds the admin SPA for service images that need