mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: per-sub-room max players, editable in game and from admin
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.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="RoomSceneEntity.MaxPlayers"/>, 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 <see cref="RoomEntity.MaxCapacity"/>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
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<DorkNetDbContext>();
|
||||
(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<DorkNetDbContext>();
|
||||
(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<JsonElement> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1097,6 +1097,81 @@ public class AdminController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/rooms/{id}/subrooms</c> — 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
|
||||
/// <see cref="RoomEntity.MaxCapacity"/> is what the room advertises and what
|
||||
/// matchmaking hands to RoomInstance.MaxCapacity, while each sub-room caps
|
||||
/// itself through <see cref="RoomSceneEntity.MaxPlayers"/>. 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.</summary>
|
||||
[HttpGet("rooms/{id:long}/subrooms")]
|
||||
public async Task<ActionResult> 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);
|
||||
|
||||
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
|
||||
/// — 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.</summary>
|
||||
[HttpPut("rooms/{id:long}/subrooms/{subRoomId:int}/maxplayers")]
|
||||
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/maxplayers")]
|
||||
public async Task<ActionResult> 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);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/roles</c> — grant a
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user