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