mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
4edf25b2d5
Targets Rec Room 2020.12.18 client.
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
272 lines
12 KiB
C#
272 lines
12 KiB
C#
using DorkNet.Server.Controllers.Match;
|
|
using DorkNet.Server.Data;
|
|
using DorkNet.Server.Data.Entities;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace DorkNet.Server.Services;
|
|
|
|
/// <summary>
|
|
/// Cross-replica registry of invite-only matches created via
|
|
/// <c>POST /goto/room/{name}?CreatePrivateInstance=True</c>.
|
|
///
|
|
/// Why this exists: the watch sets <c>CreatePrivateInstance=True</c> in
|
|
/// the matchmaking form when the player picks "Private Instance". Our
|
|
/// /goto handler responds with a unique <c>photonRoomId</c> so randos
|
|
/// can't matchmake into it. Invitees later POST
|
|
/// <c>/goto/instance/{roomInstanceId}</c> carrying the inviter's
|
|
/// instance id; that handler looks up the registered photon room
|
|
/// here so they end up in the same Photon match instead of being
|
|
/// routed to a fresh public lobby.
|
|
///
|
|
/// **Multi-replica**: state lives in two Postgres tables —
|
|
/// <see cref="PrivateInstanceEntity"/> for the instance metadata and
|
|
/// <see cref="PrivateInstanceInviteeEntity"/> for the per-invite
|
|
/// access list. Pre-PR-3 used <c>ConcurrentDictionary</c> singletons
|
|
/// which split across replicas: an invite issued on replica A wasn't
|
|
/// visible to a goto/instance hitting replica B.
|
|
///
|
|
/// Service is now <b>scoped</b> rather than singleton because it
|
|
/// holds a DbContext reference. The previous singleton lifetime would
|
|
/// have leaked the DbContext for the lifetime of the app.
|
|
/// </summary>
|
|
public class PrivateInstanceService(DorkNetDbContext db)
|
|
{
|
|
/// <summary>Generate a fresh <c>roomInstanceId</c> + <c>photonRoomId</c>
|
|
/// pair for a private match and persist the row. Caller is responsible
|
|
/// for plumbing the result onto the outgoing <see cref="RoomInstanceDto"/>.</summary>
|
|
public async Task<PrivateInstance> RegisterAsync(
|
|
long roomId, long subRoomId, long ownerPlayerId,
|
|
string baseName, string location, string dataBlob, string photonRegion, int maxCapacity)
|
|
{
|
|
// Random component so the same player can have multiple
|
|
// concurrent private instances of the same room (e.g. one in
|
|
// the lobby, one in a sub-room) — without it the photon room
|
|
// ids would collide and the second instance would silently
|
|
// matchmake into the first.
|
|
var nonce = Random.Shared.Next(0x10000, 0xFFFFFF);
|
|
// Pack roomId/subId/ownerId into the high bits of the instance
|
|
// id so it's unique-by-construction across rooms+players. The
|
|
// low 24 bits carry the nonce so multiple concurrent instances
|
|
// by the same player still get distinct ids. Stays inside int63
|
|
// for safe LitJson marshalling on the watch.
|
|
var instanceId = (long)(((ulong)(uint)nonce & 0xFFFFFFul)
|
|
^ ((ulong)roomId << 24)
|
|
^ ((ulong)subRoomId << 40)
|
|
^ ((ulong)(uint)ownerPlayerId << 48));
|
|
if (instanceId < 0) instanceId = -instanceId;
|
|
var photonRoomId = $"^{baseName}_{roomId}_sub{subRoomId}_priv{ownerPlayerId}_{nonce:x6}";
|
|
|
|
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);
|
|
await db.SaveChangesAsync();
|
|
return ToRecord(entity);
|
|
}
|
|
|
|
public async Task<PrivateInstance?> GetAsync(long instanceId)
|
|
{
|
|
var e = await db.PrivateInstances.AsNoTracking()
|
|
.FirstOrDefaultAsync(p => p.Id == instanceId);
|
|
return e is null ? null : ToRecord(e);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Idempotent registration of a player's dorm as a private instance.
|
|
/// Dorms are always owner-only by design — anyone reaching the
|
|
/// dorm's PhotonRoom name uninvited would otherwise be able to
|
|
/// drop in. We mark every dorm <c>IsPrivate=true</c> and back it
|
|
/// with a deterministic-id PrivateInstance row so
|
|
/// <see cref="CanJoinAsync"/> works the same way it does for
|
|
/// player-created private matches: owner always allowed, everyone
|
|
/// else needs an explicit invite.
|
|
///
|
|
/// The id is derived from <c>(ownerPlayerId, roomId, subRoomId)</c>
|
|
/// with a fixed high-bit marker so it never collides with the
|
|
/// random-nonce ids produced by <see cref="RegisterAsync"/>. Reusing
|
|
/// the same id across repeat <c>/goto/room/DormRoom</c> calls keeps
|
|
/// the registry from accumulating one row per dorm join.
|
|
/// </summary>
|
|
public async Task<PrivateInstance> EnsureForDormAsync(
|
|
long ownerPlayerId, long roomId, long subRoomId,
|
|
string baseName, string location, string dataBlob,
|
|
string photonRegion, int maxCapacity, string photonRoomId)
|
|
{
|
|
// Deterministic-and-distinct from RegisterAsync's nonce layout.
|
|
// Marker 0xDD0000 in the low 24 bits separates dorms from
|
|
// player-created private matches so the id spaces never alias.
|
|
var instanceId = (long)(
|
|
0xDD0000ul
|
|
^ ((ulong)(uint)roomId << 24)
|
|
^ ((ulong)(uint)subRoomId << 40)
|
|
^ ((ulong)(uint)ownerPlayerId << 48));
|
|
if (instanceId < 0) instanceId = -instanceId;
|
|
|
|
var existing = await db.PrivateInstances
|
|
.FirstOrDefaultAsync(p => p.Id == instanceId);
|
|
if (existing is not null)
|
|
{
|
|
// Keep PhotonRoomId / DataBlob / PhotonRegion current. The
|
|
// OWNER's most recent /goto/room/DormRoom is the source of
|
|
// truth for which Photon region their dorm lives in — if
|
|
// we serve invitees a stale region (because Photon:CloudRegion
|
|
// config changed since the row was first written), the
|
|
// invitee's watch connects to a different Photon master and
|
|
// joining "^dormroom_p{owner}" there creates a SEPARATE room
|
|
// from where the owner is. Both watches report Photon
|
|
// joinresult=Success but never see each other in-game —
|
|
// exact symptom 1362428/1811750 hit: same server-side
|
|
// instance id, two parallel Photon rooms.
|
|
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 (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 insert from another replica 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
|
|
// referencing a non-existent instance.
|
|
await db.PrivateInstanceInvitees
|
|
.Where(i => i.PrivateInstanceId == instanceId)
|
|
.ExecuteDeleteAsync();
|
|
await db.PrivateInstances
|
|
.Where(p => p.Id == instanceId)
|
|
.ExecuteDeleteAsync();
|
|
}
|
|
|
|
/// <summary>Add a player to the invite list for a private instance.
|
|
/// Idempotent on (instance, player) — repeat invites refresh
|
|
/// <c>LatestInviteMessageId</c> so the most-recent invite's
|
|
/// message id is always recoverable for /goto/invite resolution.
|
|
/// Returns false if the instance isn't registered (probably
|
|
/// expired / wrong id).</summary>
|
|
public async Task<bool> InviteAsync(long instanceId, long inviteePlayerId, long? messageId = null)
|
|
{
|
|
if (!await db.PrivateInstances.AnyAsync(p => p.Id == instanceId)) return false;
|
|
var existing = await db.PrivateInstanceInvitees
|
|
.FirstOrDefaultAsync(i => i.PrivateInstanceId == instanceId && i.PlayerId == inviteePlayerId);
|
|
if (existing is not null)
|
|
{
|
|
// Refresh the latest-invite pointer if this call carries
|
|
// a new messageId — the watch may send several invites in
|
|
// a row, and /goto/invite needs the freshest one.
|
|
if (messageId is long m && existing.LatestInviteMessageId != m)
|
|
{
|
|
existing.LatestInviteMessageId = m;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
return true;
|
|
}
|
|
db.PrivateInstanceInvitees.Add(new PrivateInstanceInviteeEntity
|
|
{
|
|
PrivateInstanceId = instanceId,
|
|
PlayerId = inviteePlayerId,
|
|
InvitedAt = DateTime.UtcNow,
|
|
LatestInviteMessageId = messageId,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Drop an invitee from a private instance's allow-list.
|
|
/// Used when the invitee declines via
|
|
/// <c>DELETE match.*/invite/{messageId}</c> — without this, an
|
|
/// invitee who clicks "decline" then changes their mind could
|
|
/// still join the instance without a fresh invite. No-op if the
|
|
/// invite never existed.</summary>
|
|
public async Task RevokeInviteAsync(long instanceId, long inviteePlayerId)
|
|
{
|
|
await db.PrivateInstanceInvitees
|
|
.Where(i => i.PrivateInstanceId == instanceId && i.PlayerId == inviteePlayerId)
|
|
.ExecuteDeleteAsync();
|
|
}
|
|
|
|
/// <summary>True when <paramref name="playerId"/> is the owner of
|
|
/// the instance OR has been explicitly invited. The owner is
|
|
/// implicitly allowed even without an Invite() call so they can
|
|
/// rejoin their own private match after disconnects.</summary>
|
|
public async Task<bool> CanJoinAsync(long instanceId, long playerId)
|
|
{
|
|
var ownerId = await db.PrivateInstances.AsNoTracking()
|
|
.Where(p => p.Id == instanceId)
|
|
.Select(p => (long?)p.OwnerPlayerId)
|
|
.FirstOrDefaultAsync();
|
|
if (ownerId is null) return false;
|
|
if (ownerId == playerId) return true;
|
|
return await db.PrivateInstanceInvitees.AsNoTracking()
|
|
.AnyAsync(i => i.PrivateInstanceId == instanceId && i.PlayerId == playerId);
|
|
}
|
|
|
|
private static PrivateInstance ToRecord(PrivateInstanceEntity e) => new(
|
|
InstanceId: e.Id,
|
|
RoomId: e.RoomId,
|
|
SubRoomId: e.SubRoomId,
|
|
OwnerPlayerId: e.OwnerPlayerId,
|
|
PhotonRoomId: e.PhotonRoomId,
|
|
Location: e.Location,
|
|
DataBlob: e.DataBlob,
|
|
PhotonRegion: e.PhotonRegion,
|
|
MaxCapacity: e.MaxCapacity,
|
|
Name: e.Name,
|
|
CreatedAt: DateTime.SpecifyKind(e.CreatedAt, DateTimeKind.Utc));
|
|
|
|
public sealed record PrivateInstance(
|
|
long InstanceId,
|
|
long RoomId,
|
|
long SubRoomId,
|
|
long OwnerPlayerId,
|
|
string PhotonRoomId,
|
|
string Location,
|
|
string DataBlob,
|
|
string PhotonRegion,
|
|
int MaxCapacity,
|
|
string Name,
|
|
DateTime CreatedAt);
|
|
}
|