mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -169,6 +169,81 @@ public class PrivateInstanceService(DorkNetDbContext db)
|
||||
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)
|
||||
{
|
||||
// Cascade-delete invitees first so we don't leave orphan rows
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user