mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Implement private room cloning and instance management; ensure cloned rooms start private and funnel joins into a single instance
This commit is contained in:
@@ -125,6 +125,30 @@ public class ProtoDumper : MelonMod
|
|||||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
|
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'.");
|
_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}"); }
|
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 */ }
|
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<object> 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
|
// Interception heartbeat — proves a hooked method actually ran. Logged once
|
||||||
// on first fire, so "no output" can be diagnosed as inlining (never fires)
|
// on first fire, so "no output" can be diagnosed as inlining (never fires)
|
||||||
// vs. content-not-loaded (fires but no proto-building content yet).
|
// vs. content-not-loaded (fires but no proto-building content yet).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
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<string, string> { ["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<DorkNetDbContext>();
|
||||||
|
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<string, string> { ["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<string, string> { ["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<DorkNetDbContext>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1020,7 +1020,11 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
CreatorPlayerId = Me,
|
CreatorPlayerId = Me,
|
||||||
ImageName = source.ImageName,
|
ImageName = source.ImageName,
|
||||||
State = 0,
|
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,
|
SupportsLevelVoting = source.SupportsLevelVoting,
|
||||||
// Inherit IsAGRoom from the source. This is REQUIRED for cloning a
|
// Inherit IsAGRoom from the source. This is REQUIRED for cloning a
|
||||||
// baked RRO room (RecCenter, the games): the client uses IsRRO to
|
// baked RRO room (RecCenter, the games): the client uses IsRRO to
|
||||||
|
|||||||
@@ -429,6 +429,9 @@ public class GoToController(
|
|||||||
|
|
||||||
var resp = await BuildResponseAsync(
|
var resp = await BuildResponseAsync(
|
||||||
string.IsNullOrWhiteSpace(roomName) ? "DormRoom" : roomName);
|
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);
|
await RecordResponseAsync(resp);
|
||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
@@ -463,6 +466,8 @@ public class GoToController(
|
|||||||
|
|
||||||
var resp = await BuildResponseAsync(roomName);
|
var resp = await BuildResponseAsync(roomName);
|
||||||
resp.RoomInstance!.ClubId = club.Id;
|
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);
|
await RecordResponseAsync(resp);
|
||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
@@ -528,6 +533,8 @@ public class GoToController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp = await BuildResponseAsync(roomName);
|
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);
|
await RecordResponseAsync(resp);
|
||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
@@ -708,6 +715,9 @@ public class GoToController(
|
|||||||
"[goto-invite] public-room invite {InviteId} → routing caller {Caller} to {RoomName} (RoomId={RoomId})",
|
"[goto-invite] public-room invite {InviteId} → routing caller {Caller} to {RoomName} (RoomId={RoomId})",
|
||||||
inviteId, callerId, roomName, publicRoomId);
|
inviteId, callerId, roomName, publicRoomId);
|
||||||
resp = await BuildResponseAsync(roomName);
|
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
|
else
|
||||||
@@ -1131,6 +1141,69 @@ public class GoToController(
|
|||||||
private async Task ApplyNewInstanceAsync(
|
private async Task ApplyNewInstanceAsync(
|
||||||
MatchmakingResponseDto resp, long subRoomId, bool createPrivateInstance, int joinMode)
|
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 */)
|
if (createPrivateInstance || joinMode == 2 /* PrivateNewInstance */)
|
||||||
{
|
{
|
||||||
await ApplyPrivateInstanceAsync(resp, subRoomId);
|
await ApplyPrivateInstanceAsync(resp, subRoomId);
|
||||||
|
|||||||
@@ -169,6 +169,81 @@ public class PrivateInstanceService(DorkNetDbContext db)
|
|||||||
return ToRecord(entity);
|
return ToRecord(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="EnsureForDormAsync"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PrivateInstance> 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)
|
public async Task RemoveAsync(long instanceId)
|
||||||
{
|
{
|
||||||
// Cascade-delete invitees first so we don't leave orphan rows
|
// Cascade-delete invitees first so we don't leave orphan rows
|
||||||
|
|||||||
@@ -1029,7 +1029,9 @@ public class RoomService(DorkNetDbContext db)
|
|||||||
CreatorPlayerId = playerId,
|
CreatorPlayerId = playerId,
|
||||||
ImageName = "",
|
ImageName = "",
|
||||||
State = 0,
|
State = 0,
|
||||||
Accessibility = source.Accessibility,
|
// Clones start private — same rule as BareClone; the owner
|
||||||
|
// publishes explicitly when ready.
|
||||||
|
Accessibility = 0,
|
||||||
SupportsLevelVoting = source.SupportsLevelVoting,
|
SupportsLevelVoting = source.SupportsLevelVoting,
|
||||||
// User-cloned rooms are NOT AG-managed: the AG flag means
|
// User-cloned rooms are NOT AG-managed: the AG flag means
|
||||||
// "owned by the Against Gravity seed account, baked into
|
// "owned by the Against Gravity seed account, baked into
|
||||||
|
|||||||
@@ -103,6 +103,23 @@ Blob semantics on clone (`RoomsModerationController.BareClone`):
|
|||||||
- **User-owned source** (including AG-flagged clones of RROs): blobs
|
- **User-owned source** (including AG-flagged clones of RROs): blobs
|
||||||
are copied so "copy room" carries the player's MakerPen edits.
|
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
|
## Related 2023 quirks fixed alongside
|
||||||
|
|
||||||
- **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls
|
- **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls
|
||||||
|
|||||||
Reference in New Issue
Block a user