feat: Rec Royale night mode (ClientMod) + admin switch to give baked RRO rooms a data blob

ClientMod (2023-03-21): opt-in Rec Royale night mode. RRO scenes never
enable the room mood engine (no data blob → SceneMoodSettings.isEnabled
stays false, CurrentMood null, the per-frame update returns at once), so
the mod replays the room-data loader's bring-up, registers a shipped
night MoodSetting (Night_Calm_Outdoor_Mood by default) as the scene's
default mood, supplies a skybox material on the Custom/SkyBox shader
(MoodConfig.SkyBoxMaterial is not loaded outside custom rooms), assigns
the scene's directional light as RenderSettings.sun, matches the key
light to the engine-driven sun, darkens via the mood's post-exposure and
sun/ambient intensities, forces post-processing on, keeps or disables
fog, hides the daytime cloud meshes, unlocks weapon flashlights, and
restores everything on hotkey-off / scene change. All knobs live in
dorknet-clientmod.json (RecRoyaleNight*), written by install-melon.ps1.

Server/admin: baked Rec Room Originals are seeded with an empty
DataBlobName, which the 2023 client reads as "no room data" — no Maker
Pen, no CV2 room-settings chips, no saves, and for Rec Royale no game at
all (its game-rule chip RecRoyaleSolos_GameRuleWrapperCircuit ships in
the blob, not the scene). New admin actions on the Sub-rooms tab:
POST rooms/{id}/subrooms/{sub}/blob/enable stamps the synthetic default
blob name (which CdnController already answers with the RRO-editable
blob) and blob/reset clears it back to scene-only. IsRRO is derived from
IsAGRoom, so the client keeps loading the baked map.

Docs: README section, docs/admin.md procedure, docs/recroom-2023-room-save.md,
new docs/recroom-2023-scene-moods.md with the ISIL offsets behind all of it.
Tests: RroRoomBlobTests.
This commit is contained in:
Alexa
2026-09-27 22:45:40 +03:00
parent cf3aa29ba2
commit 258183fb94
12 changed files with 2300 additions and 7 deletions
+177
View File
@@ -0,0 +1,177 @@
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,
});
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);
// ── 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());
}
[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;
}
}