mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
f43ce3f368
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.
165 lines
7.8 KiB
C#
165 lines
7.8 KiB
C#
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;
|
|
}
|
|
}
|