mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
27314c1335
An enabled baked room still had no Maker Pen: the client fetched the CDN's
synthetic stub for room_{id}_dorknet_v8.dat and silently loaded the room
with no room roles. Measured against Rec Center's working room_100_v1.dat
(10,930 bytes, the same client loads it with Maker Pen), the old 1,872-byte
roles-only stub was stamped PersistedRoomVersion 19 — past the March-2023
client's ceiling of 16, which only the S3-hit path clamped — and lacked the
skeleton every real save carries (last_save_time, activity_id = the room's
LocationReplicationId, connectable graph, scene settings, object model,
room_mood_* blocks).
RoomDataBlobService.BuildRroEditableBlob now emits that skeleton per room
at version 16 (untyped fields appended on the wire; payloads are Rec
Center's defaults verbatim). CdnController resolves the RRO room for the
miss path, passes its location id, and clamps synthesised blobs the same
way it clamps S3 hits. Tests pin version 16, the activity id and
CanUseMakerPen on every role.
248 lines
13 KiB
C#
248 lines
13 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
using DorkNet.Server.Data;
|
|
using DorkNet.Server.Data.Entities;
|
|
using DorkNet.Server.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using RecRoom.Protobuf;
|
|
|
|
namespace DorkNet.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty
|
|
/// <see cref="RoomSceneEntity.DataBlobName"/>, and every details builder and
|
|
/// matchmaking resolver turns that into an empty <c>DataBlob</c>. The client
|
|
/// reads that as "no room data at all": no persistence views, no room-role
|
|
/// overlay, no Maker Pen, no CV2 chips, no room mood engine. Real Rec Room
|
|
/// served these rooms a blob — Rec Royale's game-rule chip lives in it, not
|
|
/// in the scene — so DorkNet needs a switch that gives such a room one.
|
|
///
|
|
/// The property pinned here: the admin "enable blob" action stamps the
|
|
/// synthetic default name, that name flows through the game-client details
|
|
/// verbatim while <c>IsRRO</c> stays true (the client still loads the baked
|
|
/// map, and only additionally fetches the blob), the CDN answers that name
|
|
/// with the RRO-editable blob (permissive room roles, no objects), and
|
|
/// "reset" puts everything back to scene-only.
|
|
/// </summary>
|
|
public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
|
{
|
|
private readonly DorkNetServerFactory _factory;
|
|
|
|
public RroRoomBlobTests(DorkNetServerFactory factory) => _factory = factory;
|
|
|
|
[Fact]
|
|
public async Task Baked_original_gets_no_blob_until_an_admin_enables_one()
|
|
{
|
|
using var setup = Client("rooms");
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
|
|
var roomId = 9_600_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;
|
|
|
|
// Shape of a seeded baked original: system-owned, AG-flagged,
|
|
// tagged recroomoriginal, no blob anywhere.
|
|
db.Rooms.Add(new RoomEntity
|
|
{
|
|
Id = roomId,
|
|
Name = $"Royale{Guid.NewGuid():N}"[..18],
|
|
CreatorPlayerId = 1,
|
|
IsAGRoom = true,
|
|
TagsCsv = "recroomoriginal,sport",
|
|
CurrentDataBlobName = string.Empty,
|
|
LocationReplicationId = "b010171f-4875-4e89-baba-61e878cd41e1",
|
|
});
|
|
db.RoomScenes.Add(new RoomSceneEntity
|
|
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
|
using var gameClient = Client("rooms", player);
|
|
using var adminClient = Client("admin", admin);
|
|
using var cdnClient = Client("cdn");
|
|
|
|
// ── before: baked scene only ────────────────────────────────────────
|
|
var before = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
|
Assert.True(before.GetProperty("IsRRO").GetBoolean());
|
|
Assert.Equal(string.Empty, SubRoomZero(before).GetProperty("DataBlob").GetString());
|
|
|
|
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
|
var row = listed.EnumerateArray().Single();
|
|
Assert.True(row.GetProperty("isBakedOriginal").GetBoolean());
|
|
Assert.Equal(string.Empty, row.GetProperty("effectiveBlobName").GetString());
|
|
|
|
// ── enable: the synthetic name lands on the scene AND the room ─────
|
|
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
|
Assert.True(enabled.GetProperty("changed").GetBoolean());
|
|
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
|
|
Assert.Equal(synthetic, enabled.GetProperty("dataBlobName").GetString());
|
|
Assert.Equal(synthetic, enabled.GetProperty("roomCurrentDataBlobName").GetString());
|
|
|
|
// Idempotent: a second enable changes nothing and keeps the name.
|
|
var again = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
|
Assert.False(again.GetProperty("changed").GetBoolean());
|
|
Assert.Equal(synthetic, again.GetProperty("effectiveBlobName").GetString());
|
|
|
|
// The game client now gets the blob name, and IsRRO is untouched —
|
|
// that flag is what makes it load the baked map (a false there sends
|
|
// it looking for geometry inside the blob and the join fails).
|
|
var after = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
|
Assert.True(after.GetProperty("IsRRO").GetBoolean());
|
|
Assert.Equal(synthetic, SubRoomZero(after).GetProperty("DataBlob").GetString());
|
|
|
|
// ── the CDN answers that name with the RRO-editable blob ───────────
|
|
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
|
|
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
|
|
Assert.True(blobResponse.IsSuccessStatusCode,
|
|
$"GET cdn /room/{synthetic} -> {(int)blobResponse.StatusCode}");
|
|
var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes);
|
|
Assert.NotNull(persisted.RoomRoleData);
|
|
Assert.NotEmpty(persisted.RoomRoleData.RoomRoles);
|
|
Assert.True(persisted.RoomRoleData.RoomRoles.All(r => r.CanUseMakerPen is { Overrides: true, InnerValue: true }));
|
|
|
|
// The March-2023 client rejects a blob past PersistedRoomVersion 16
|
|
// (the old stub was stamped 19 and loaded as "no room data"), and a
|
|
// real save carries the room's location id as activity_id. Our proto
|
|
// doesn't type activity_id, so look for it on the wire.
|
|
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)persisted.Version);
|
|
Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", System.Text.Encoding.UTF8.GetString(blobBytes));
|
|
|
|
// ── reset: back to scene-only ───────────────────────────────────────
|
|
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
|
Assert.True(reset.GetProperty("changed").GetBoolean());
|
|
Assert.Equal(string.Empty, reset.GetProperty("effectiveBlobName").GetString());
|
|
|
|
var restored = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
|
Assert.True(restored.GetProperty("IsRRO").GetBoolean());
|
|
Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString());
|
|
}
|
|
|
|
/// <summary>The seeded Rec Royale rows have NO RoomScenes at all (the admin
|
|
/// page showed "Scenes 0 / No sub-rooms"). The admin list must synthesise
|
|
/// the entry scene the way the game-client details do, and enable must
|
|
/// create the row rather than 404.</summary>
|
|
[Fact]
|
|
public async Task Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it()
|
|
{
|
|
using var setup = Client("rooms");
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
|
|
var roomId = 9_670_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 = $"NoScene{Guid.NewGuid():N}"[..18],
|
|
CreatorPlayerId = 1,
|
|
IsAGRoom = true,
|
|
TagsCsv = "recroomoriginal",
|
|
MaxCapacity = 24,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
|
using var gameClient = Client("rooms", player);
|
|
using var adminClient = Client("admin", admin);
|
|
|
|
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
|
var virtualRow = listed.EnumerateArray().Single();
|
|
Assert.True(virtualRow.GetProperty("isVirtual").GetBoolean());
|
|
Assert.Equal(0, virtualRow.GetProperty("subRoomId").GetInt32());
|
|
Assert.Equal(24, virtualRow.GetProperty("maxPlayers").GetInt32());
|
|
Assert.Equal(string.Empty, virtualRow.GetProperty("effectiveBlobName").GetString());
|
|
|
|
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
|
Assert.True(enabled.GetProperty("changed").GetBoolean());
|
|
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
|
|
|
|
// The row now exists with the game-side defaults, and the game client
|
|
// sees the blob on its entry scene.
|
|
var relisted = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
|
var real = relisted.EnumerateArray().Single();
|
|
Assert.False(real.TryGetProperty("isVirtual", out _));
|
|
Assert.Equal("Home", real.GetProperty("name").GetString());
|
|
Assert.Equal(24, real.GetProperty("maxPlayers").GetInt32());
|
|
Assert.Equal(synthetic, real.GetProperty("dataBlobName").GetString());
|
|
|
|
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
|
Assert.True(details.GetProperty("IsRRO").GetBoolean());
|
|
Assert.Equal(synthetic, SubRoomZero(details).GetProperty("DataBlob").GetString());
|
|
|
|
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
|
Assert.True(reset.GetProperty("changed").GetBoolean());
|
|
Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob()
|
|
{
|
|
using var setup = Client("rooms");
|
|
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
|
|
var roomId = 9_650_000 + Random.Shared.Next(1, 99_999);
|
|
const string saved = "room_saved_by_a_player_v3.dat";
|
|
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 = $"Saved{Guid.NewGuid():N}"[..18],
|
|
CreatorPlayerId = 1,
|
|
IsAGRoom = true,
|
|
TagsCsv = "recroomoriginal",
|
|
CurrentDataBlobName = saved,
|
|
});
|
|
db.RoomScenes.Add(new RoomSceneEntity
|
|
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = saved });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
using var adminClient = Client("admin", admin);
|
|
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
|
Assert.False(enabled.GetProperty("changed").GetBoolean());
|
|
Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString());
|
|
}
|
|
|
|
private static JsonElement SubRoomZero(JsonElement details) =>
|
|
details.GetProperty("SubRooms").EnumerateArray()
|
|
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
|
|
|
|
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 static async Task<JsonElement> PostJsonAsync(HttpClient client, string path)
|
|
{
|
|
using var response = await client.PostAsync(path, content: null);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.True(response.IsSuccessStatusCode, $"POST {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;
|
|
}
|
|
}
|