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 the second sub-room only ─────────
// The client addresses it by its wire SubRoomId, OrderIndex + 1 = 2
// (RoomSceneIds — the client will not load a sub-room whose id is 0).
// 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/2/maxplayers", new { maxPlayers = 24 });
var sliderBody = await slider.Content.ReadAsStringAsync();
Assert.True(slider.IsSuccessStatusCode,
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
// The client reads the reply through the {success, value, error}
// helper with value = FGCPNAACHIK, so the slider's own response has to
// carry the new value inside the envelope.
var envelope = JsonDocument.Parse(sliderBody).RootElement;
Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
var echoed = envelope.GetProperty("value")
.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
// (the admin API addresses sub-rooms by OrderIndex)
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());
// Wire ids are OrderIndex + 1.
Assert.Equal(40, wire[1]);
Assert.Equal(24, wire[2]);
// 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;
}
}