From 6ee018b844fb0d7a5623799ea94a098245fd51db Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:10:12 +0300 Subject: [PATCH] feat: Implement private room cloning and instance management; ensure cloned rooms start private and funnel joins into a single instance --- DorkNet.ProtoDumper/ProtoDumper.cs | 52 +++++++ .../PrivateRoomInstanceTests.cs | 145 ++++++++++++++++++ .../API/Rooms/V2/RoomsModerationController.cs | 6 +- .../Controllers/Match/GoToController.cs | 73 +++++++++ .../Services/PrivateInstanceService.cs | 75 +++++++++ DorkNet.Server/Services/RoomService.cs | 4 +- docs/recroom-2023-room-save.md | 17 ++ 7 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 DorkNet.Server.Tests/PrivateRoomInstanceTests.cs diff --git a/DorkNet.ProtoDumper/ProtoDumper.cs b/DorkNet.ProtoDumper/ProtoDumper.cs index b8cbda6..4dd5b71 100644 --- a/DorkNet.ProtoDumper/ProtoDumper.cs +++ b/DorkNet.ProtoDumper/ProtoDumper.cs @@ -125,6 +125,30 @@ public class ProtoDumper : MelonMod catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); } } } + + // STRONGEST hook: DescriptorPool.AddSymbol(IDescriptor). Called once + // per message/enum/service symbol while a descriptor is built. Unlike + // the tiny BuildFrom/FromGeneratedCode/ctor helpers, AddSymbol does + // real work (name validation + dictionary insert + duplicate-throw), + // so the AOT compiler does NOT inline it away — the managed patch + // actually fires. From the symbol we walk `.File` to the owning + // FileDescriptor and collect its serialized bytes. We also stash the + // pool so ScanPools() can later enumerate everything it holds. + Type? pool = ResolveType("Google.Protobuf.Reflection.DescriptorPool"); + if (pool != null) + { + var symPostfix = new HarmonyMethod( + typeof(ProtoDumper).GetMethod(nameof(AddSymbol_Postfix), + BindingFlags.Public | BindingFlags.Static)); + foreach (MethodInfo m in pool.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (m.Name != "AddSymbol" && m.Name != "AddPackage") continue; + try { HarmonyInstance.Patch(m, postfix: symPostfix); patched++; _log?.Msg($"ProtoDumper: hooked DescriptorPool.{m.Name}({m.GetParameters().Length} args)"); } + catch (Exception e) { _log?.Warning($"ProtoDumper: patch DescriptorPool.{m.Name} failed: {e.Message}"); } + } + } + else _log?.Warning("ProtoDumper: DescriptorPool type not found — AddSymbol hook skipped."); + _log?.Msg($"ProtoDumper: installed {patched} descriptor hooks. Play through your content; descriptors are captured as they load. Watch for 'FIRST DESCRIPTOR CAPTURED'."); } catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); } @@ -175,6 +199,34 @@ public class ProtoDumper : MelonMod catch { /* never let our hook disturb the game */ } } + // Live DescriptorPool references seen via AddSymbol, kept so ScanPools() can + // enumerate every descriptor a pool holds (already-built → no force-build). + private static readonly List Pools = new(); + + // Postfix on DescriptorPool.AddSymbol / AddPackage. __instance is the pool, + // __0 is the IDescriptor symbol being registered. Walk symbol.File to the + // owning FileDescriptor and collect it. + public static void AddSymbol_Postfix(object? __instance, object? __0) + { + Fired(); + try + { + if (__instance != null) + { + lock (Gate) + { + bool known = false; + foreach (var p in Pools) if (ReferenceEquals(p, __instance)) { known = true; break; } + if (!known) Pools.Add(__instance); + } + } + if (__0 == null) return; + object? file = GetProp(__0, "File"); + if (file != null) CollectFile(file); + } + catch { /* never let our hook disturb the game */ } + } + // Interception heartbeat — proves a hooked method actually ran. Logged once // on first fire, so "no output" can be diagnosed as inlining (never fires) // vs. content-not-loaded (fires but no proto-building content yet). diff --git a/DorkNet.Server.Tests/PrivateRoomInstanceTests.cs b/DorkNet.Server.Tests/PrivateRoomInstanceTests.cs new file mode 100644 index 0000000..419da5c --- /dev/null +++ b/DorkNet.Server.Tests/PrivateRoomInstanceTests.cs @@ -0,0 +1,145 @@ +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; + +namespace DorkNet.Server.Tests; + +/// +/// Private-room semantics: +/// 1. A freshly cloned room is PRIVATE (Accessibility=0) regardless of +/// the source's visibility — the owner publishes explicitly. +/// 2. A private room has exactly ONE instance per sub-room: every +/// JoinMode (plain matchmake / new public / new private) funnels +/// into the same deterministic invite-gated instance instead of +/// nonce-forking parallel copies. +/// 3. Players who are neither the creator nor role-holders/invitees +/// are turned away with ErrorCode=4 (indistinguishable from a +/// nonexistent room, so private rooms don't leak). +/// +public sealed class PrivateRoomInstanceTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public PrivateRoomInstanceTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Clone_is_private_by_default() + { + using var setupClient = ApiClient(); + var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + + var sourceId = 9_400_100L; + await SeedRoomAsync(sourceId, creatorId: owner.PlayerId, accessibility: 1); + + using var roomsClient = ApiClient(owner, subdomain: "rooms"); + using var resp = await roomsClient.PostAsync( + $"/rooms/{sourceId}/clone", + new FormUrlEncodedContent(new Dictionary { ["name"] = $"PrivClone_{Guid.NewGuid():N}"[..24] })); + var text = await resp.Content.ReadAsStringAsync(); + Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}"); + using var json = JsonDocument.Parse(text); + var cloneId = json.RootElement.GetProperty("RoomId").GetInt64(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var clone = await db.Rooms.SingleAsync(r => r.Id == cloneId); + Assert.Equal(0, clone.Accessibility); // private by default + } + + [Fact] + public async Task Private_room_gets_one_instance_per_subroom_for_all_join_modes() + { + using var setupClient = ApiClient(); + var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + + var roomId = 9_400_200L; + await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0); + + using var matchClient = ApiClient(owner, subdomain: "match"); + + var joins = new List<(long InstanceId, string Photon, bool IsPrivate)>(); + foreach (var joinMode in new[] { 0, 1, 2, 0 }) + { + using var resp = await matchClient.PostAsync( + $"/matchmake/room/{roomId}", + new FormUrlEncodedContent(new Dictionary { ["JoinMode"] = joinMode.ToString() })); + var text = await resp.Content.ReadAsStringAsync(); + Assert.True(resp.IsSuccessStatusCode, $"JoinMode={joinMode} -> {(int)resp.StatusCode}: {text}"); + using var json = JsonDocument.Parse(text); + Assert.Equal(0, json.RootElement.GetProperty("errorCode").GetInt32()); + var inst = json.RootElement.GetProperty("roomInstance"); + joins.Add(( + inst.GetProperty("roomInstanceId").GetInt64(), + inst.GetProperty("photonRoomId").GetString()!, + inst.GetProperty("isPrivate").GetBoolean())); + } + + // Every join mode must land in the SAME single instance. + Assert.Single(joins.Select(j => j.InstanceId).Distinct()); + Assert.Single(joins.Select(j => j.Photon).Distinct()); + Assert.All(joins, j => Assert.True(j.IsPrivate, "private room instances must be invite-gated")); + } + + [Fact] + public async Task Private_room_turns_away_uninvited_players() + { + using var setupClient = ApiClient(); + var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + var stranger = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + + var roomId = 9_400_300L; + await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0); + + using var strangerClient = ApiClient(stranger, subdomain: "match"); + using var resp = await strangerClient.PostAsync( + $"/matchmake/room/{roomId}", + new FormUrlEncodedContent(new Dictionary { ["JoinMode"] = "0" })); + var text = await resp.Content.ReadAsStringAsync(); + Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}"); + + using var json = JsonDocument.Parse(text); + // 4 = RoomDoesNotExist — private rooms are invisible, not "forbidden". + Assert.Equal(4, json.RootElement.GetProperty("errorCode").GetInt32()); + } + + private async Task SeedRoomAsync(long roomId, long creatorId, int accessibility) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (await db.Rooms.AnyAsync(r => r.Id == roomId)) return; + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = $"PrivRoom_{roomId}", + Description = "private-room test", + CreatorPlayerId = creatorId, + ImageName = RoomService.DefaultRoomImageName, + Accessibility = accessibility, + CloningAllowed = true, + LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163", + }); + db.RoomScenes.Add(new RoomSceneEntity + { + RoomId = roomId, + OrderIndex = 0, + Name = "Home", + }); + await db.SaveChangesAsync(); + } + + private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api") + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } +} diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index a38eacf..d0c5983 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -1020,7 +1020,11 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase CreatorPlayerId = Me, ImageName = source.ImageName, State = 0, - Accessibility = source.Accessibility, + // Clones start PRIVATE regardless of the source's visibility — + // a fresh copy is the owner's workbench, not something the + // public should matchmake into until they flip it public + // themselves (rooms/{id}/accessibility). + Accessibility = 0, SupportsLevelVoting = source.SupportsLevelVoting, // Inherit IsAGRoom from the source. This is REQUIRED for cloning a // baked RRO room (RecCenter, the games): the client uses IsRRO to diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index ef1aaeb..b22d560 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -429,6 +429,9 @@ public class GoToController( var resp = await BuildResponseAsync( string.IsNullOrWhiteSpace(roomName) ? "DormRoom" : roomName); + // joinMode 0 is a no-op for public rooms; it only funnels private + // rooms into their single per-subroom instance. + await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance: false, joinMode: 0); await RecordResponseAsync(resp); return Ok(resp); } @@ -463,6 +466,8 @@ public class GoToController( var resp = await BuildResponseAsync(roomName); resp.RoomInstance!.ClubId = club.Id; + // Funnel private clubhouse rooms into their single instance. + await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance: false, joinMode: 0); await RecordResponseAsync(resp); return Ok(resp); } @@ -528,6 +533,8 @@ public class GoToController( } var resp = await BuildResponseAsync(roomName); + // Funnel private rooms into their single per-subroom instance. + await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance: false, joinMode: 0); await RecordResponseAsync(resp); return Ok(resp); } @@ -708,6 +715,9 @@ public class GoToController( "[goto-invite] public-room invite {InviteId} → routing caller {Caller} to {RoomName} (RoomId={RoomId})", inviteId, callerId, roomName, publicRoomId); resp = await BuildResponseAsync(roomName); + // Private rooms pin to their single per-subroom instance so + // the invitee lands with the inviter, not in a parallel copy. + await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance: false, joinMode: 0); } } else @@ -1131,6 +1141,69 @@ public class GoToController( private async Task ApplyNewInstanceAsync( MatchmakingResponseDto resp, long subRoomId, bool createPrivateInstance, int joinMode) { + // Private ROOMS (Accessibility=0) allow exactly ONE instance per + // sub-room: whatever the watch asked for (plain matchmake, "new + // instance", "private instance"), everyone funnels into the same + // deterministic invite-gated match. Without this, JoinMode=1/2 + // nonce-forks fragment the owner's private room into parallel + // instances that can never see each other. Dorms are already + // stamped with their own deterministic private instance in + // BuildResponseAsync — leave them untouched. + var room = await db.Rooms.AsNoTracking() + .Where(r => r.Id == resp.RoomInstance.RoomId) + .Select(r => new { r.Accessibility, r.IsDormRoom, r.CreatorPlayerId }) + .FirstOrDefaultAsync(); + if (room is { Accessibility: 0, IsDormRoom: false }) + { + // Owner of the instance = the ROOM's creator (not the caller): + // invite authority and rejoin rights belong to the room owner + // even when an invitee's join is what materialised the row. + var inst = await privateInstances.EnsureForPrivateRoomAsync( + ownerPlayerId: room.CreatorPlayerId, + roomId: resp.RoomInstance.RoomId, + subRoomId: subRoomId, + baseName: resp.RoomInstance.Name.ToLowerInvariant(), + location: resp.RoomInstance.Location, + dataBlob: resp.RoomInstance.DataBlob, + photonRegion: resp.RoomInstance.PhotonRegionId, + maxCapacity: resp.RoomInstance.MaxCapacity, + photonRoomId: resp.RoomInstance.PhotonRoomId); + + // With everyone sharing ONE instance, access control matters + // more than it did when strangers forked into parallel copies: + // an uninvited join would land IN the owner's session. Allow + // the creator, accepted role holders (co-owner/mod/host), and + // instance invitees; everyone else gets the same "room not + // found" the browse filters imply (private rooms are invisible, + // so admitting the room exists would leak more than a 4 does). + var caller = this.CurrentPlayerId(); + var allowed = caller is long callerPid + && (callerPid == room.CreatorPlayerId + || await privateInstances.CanJoinAsync(inst.InstanceId, callerPid) + || await db.RoomRoles.AsNoTracking().AnyAsync(rr => + rr.RoomId == resp.RoomInstance.RoomId + && rr.PlayerId == callerPid + && rr.Accepted)); + if (!allowed) + { + logger.LogWarning( + "[goto-privateroom] denied player={Player} room={Room} sub={Sub} — not creator/role/invitee", + caller, resp.RoomInstance.RoomId, subRoomId); + resp.ErrorCode = 4; // RoomDoesNotExist — don't leak private rooms + resp.RoomInstance = null; + return; + } + + resp.RoomInstance.RoomInstanceId = inst.InstanceId; + resp.RoomInstance.PhotonRoomId = inst.PhotonRoomId; + resp.RoomInstance.IsPrivate = true; + logger.LogInformation( + "[goto-privateroom] room={Room} sub={Sub} owner={Owner} joinMode={Mode} → single instance instId={Id} photon={Photon}", + resp.RoomInstance.RoomId, subRoomId, room.CreatorPlayerId, + joinMode, inst.InstanceId, inst.PhotonRoomId); + return; + } + if (createPrivateInstance || joinMode == 2 /* PrivateNewInstance */) { await ApplyPrivateInstanceAsync(resp, subRoomId); diff --git a/DorkNet.Server/Services/PrivateInstanceService.cs b/DorkNet.Server/Services/PrivateInstanceService.cs index d4b6208..8cc7f39 100644 --- a/DorkNet.Server/Services/PrivateInstanceService.cs +++ b/DorkNet.Server/Services/PrivateInstanceService.cs @@ -169,6 +169,81 @@ public class PrivateInstanceService(DorkNetDbContext db) return ToRecord(entity); } + /// + /// Idempotent registration of THE single instance of a private room's + /// sub-room. Private rooms (Accessibility=0) allow exactly ONE instance + /// per sub-room: every join — plain matchmake, "new instance", "private + /// instance" — funnels into the same deterministic match, so the owner + /// and their invitees always end up together instead of fragmenting + /// across nonce-forked instances. + /// + /// Same upsert pattern as but keyed on + /// (roomId, subRoomId) ONLY — the instance belongs to the room, not to + /// whoever happened to join first. Marker 0xEE0000 keeps the id space + /// disjoint from dorms (0xDD0000) and nonce-based RegisterAsync ids. + /// OwnerPlayerId tracks the room's CURRENT creator so ownership + /// transfers (rooms/{id}/creator) move invite authority with the room. + /// + public async Task EnsureForPrivateRoomAsync( + long ownerPlayerId, long roomId, long subRoomId, + string baseName, string location, string dataBlob, + string photonRegion, int maxCapacity, string photonRoomId) + { + var instanceId = (long)( + 0xEE0000ul + ^ ((ulong)(uint)roomId << 24) + ^ ((ulong)(uint)subRoomId << 40)); + if (instanceId < 0) instanceId = -instanceId; + + var existing = await db.PrivateInstances + .FirstOrDefaultAsync(p => p.Id == instanceId); + if (existing is not null) + { + // Keep the row current — same staleness rules as dorms (the + // Photon region / blob must follow the latest join or invitees + // get parked in a parallel Photon room). + var changed = false; + if (existing.PhotonRoomId != photonRoomId) { existing.PhotonRoomId = photonRoomId; changed = true; } + if (existing.DataBlob != dataBlob) { existing.DataBlob = dataBlob; changed = true; } + if (existing.Location != location) { existing.Location = location; changed = true; } + if (existing.PhotonRegion != photonRegion) { existing.PhotonRegion = photonRegion; changed = true; } + if (existing.Name != baseName) { existing.Name = baseName; changed = true; } + if (existing.OwnerPlayerId != ownerPlayerId) { existing.OwnerPlayerId = ownerPlayerId; changed = true; } + if (changed) await db.SaveChangesAsync(); + return ToRecord(existing); + } + + var entity = new PrivateInstanceEntity + { + Id = instanceId, + RoomId = roomId, + SubRoomId = subRoomId, + OwnerPlayerId = ownerPlayerId, + PhotonRoomId = photonRoomId, + Location = location, + DataBlob = dataBlob, + PhotonRegion = photonRegion, + MaxCapacity = maxCapacity, + Name = baseName, + CreatedAt = DateTime.UtcNow, + }; + db.PrivateInstances.Add(entity); + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + // Concurrent join raced us to the same deterministic id — + // re-fetch the winning row. + db.Entry(entity).State = EntityState.Detached; + var raced = await db.PrivateInstances.AsNoTracking() + .FirstAsync(p => p.Id == instanceId); + return ToRecord(raced); + } + return ToRecord(entity); + } + public async Task RemoveAsync(long instanceId) { // Cascade-delete invitees first so we don't leave orphan rows diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index d090eea..a11c1d7 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -1029,7 +1029,9 @@ public class RoomService(DorkNetDbContext db) CreatorPlayerId = playerId, ImageName = "", State = 0, - Accessibility = source.Accessibility, + // Clones start private — same rule as BareClone; the owner + // publishes explicitly when ready. + Accessibility = 0, SupportsLevelVoting = source.SupportsLevelVoting, // User-cloned rooms are NOT AG-managed: the AG flag means // "owned by the Against Gravity seed account, baked into diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md index 6ee6002..d4dc5f9 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -103,6 +103,23 @@ Blob semantics on clone (`RoomsModerationController.BareClone`): - **User-owned source** (including AG-flagged clones of RROs): blobs are copied so "copy room" carries the player's MakerPen edits. +Clones are created **private** (`Accessibility=0`) regardless of the +source's visibility; the owner publishes via +`rooms/{id}/accessibility` when ready. + +### Private rooms: one instance per sub-room + +Private rooms (`Accessibility=0`) allow exactly ONE instance per +sub-room. `GoToController.ApplyNewInstanceAsync` funnels every join +mode (plain matchmake, JoinMode=1 new-public, JoinMode=2 new-private) +into the same deterministic invite-gated instance +(`PrivateInstanceService.EnsureForPrivateRoomAsync`, id marker +`0xEE0000`, keyed on room+subroom — dorms keep their own `0xDD0000` +flow). Joins by players who are neither the creator, an accepted +role-holder (co-owner/mod/host), nor an instance invitee return +`errorCode=4` (RoomDoesNotExist) so private rooms don't leak their +existence. Tests: `PrivateRoomInstanceTests.cs`. + ## Related 2023 quirks fixed alongside - **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls