using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json.Serialization;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.Messages.V2;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Match;
///
/// match.rec.net/goto/* — matchmaking endpoints used by
/// RecNet.Matchmaking.GoToHelper / GoToDormRoom (the latter is what
/// BootSequence.LoadDormRoom invokes during initial scene load).
///
/// Response shape verified by disassembling
/// RecNet.Matchmaking.MatchmakingResponse.Deserialize (RVA 0x1447DD0):
/// "errorCode" - int, optional (Util.GetKeyOrDefault<int>)
/// "roomInstance" - RoomInstance object, REQUIRED. The deserializer
/// throws explicitly with the error
/// "Matchmaking response has no RoomInstance field"
/// when this key is missing or non-object.
///
/// The nested RoomInstance shape (RVA 0x114DAB0) requires:
/// roomInstanceId, roomId, subRoomId, location, photonRegionId,
/// photonRoomId, maxCapacity, isFull, isPrivate, isInProgress
/// (all required) plus optional dataBlob, eventId, name.
///
/// The client then JOINS the Photon room named by photonRoomId in the
/// region photonRegionId — these two values are what tie our HTTP
/// matchmaking to the actual realtime networking.
///
[ApiController]
public class GoToController(
IConfiguration config,
PlayerPresenceService presence,
PrivateInstanceService privateInstances,
RoomService rooms,
DorkNet.Server.Data.DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
JoinTimeoutService joinTimeouts,
ServerSettingsService serverSettings,
PresenceBroadcastService presenceFanout,
ILogger logger) : ControllerBase
{
[HttpPost("/goto/room/{roomName}")]
public async Task> GoToRoom(
string roomName,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
// The December watch signals "make a new instance" via the
// JoinMode form field, NOT CreatePrivateInstance (which it never
// sends — verified on the wire: a "New Private Instance" request
// carries `JoinMode=2` and no CreatePrivateInstance key). Maps to
// RecNet.Matchmaking's GIJOGKJCEKG enum:
// 0 = PublicMatchmaking (join the existing shared instance)
// 1 = PublicNewInstance (fork a fresh PUBLIC instance)
// 2 = PrivateNewInstance (fork a fresh, invite-gated instance)
// Without honouring this, JoinMode 1/2 fell through to the
// default branch and returned the SAME roomInstanceId+photonRoomId
// the player was already standing in. Per the sub-room note below,
// identical instance id + photon room => the watch's transition
// state machine never fires and "Going to " hangs forever
// (the player softlocks; the join-watchdog then times out at 30s).
[FromForm(Name = "JoinMode")] int joinMode = 0,
// Party members the watch wants to bring along (added as repeated
// `AdditionalPlayerIds` form fields by RecNet.Matchmaking — verified
// at Matchmaking.txt:5798-5806). Each gets a follow invite once the
// leader's new instance is built. See NotifyAdditionalPlayersAsync.
[FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null)
{
// Unknown room name shouldn't silently drop the player into a
// synth'd dorm scene any more — that masked typo bug reports
// ("why does /goto/^Crescendoo land me in my dorm?") and let
// dead links sit unfixed. Surface the real "no such room"
// error so the watch shows its standard "room not found" toast
// and the typo is obvious. DormRoom is a special case (per-player
// lazy-create), everything else MUST exist in db.Rooms.
if (!roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase))
{
var exists = await rooms.GetByNameAsync(roomName);
if (exists is null)
{
logger.LogWarning("[goto] unknown room name='{Name}' — returning RoomDoesNotExist", roomName);
return Ok(new MatchmakingResponseDto
{
// 4 = RoomDoesNotExist on
// RecNet.Matchmaking.CreateModifyRoomStatus enum.
// The watch reads ErrorCode != 0 and surfaces the
// standard "Room not found" toast.
ErrorCode = 4,
RoomInstance = null,
});
}
}
var resp = await BuildResponseAsync(roomName);
await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode);
await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
return Ok(resp);
}
[HttpPost("/goto/room")]
public async Task> GoToRoomFromBody(
[FromForm(Name = "RoomName")] string? roomNameForm,
[FromForm(Name = "roomName")] string? roomNameLower,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null)
{
var roomName = roomNameForm
?? roomNameLower
?? Request.Query["RoomName"].FirstOrDefault()
?? Request.Query["roomName"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(roomName))
return BadRequest(new { error = "missing roomName" });
return await GoToRoom(roomName, createPrivateInstance, joinMode, additionalPlayerIds);
}
[HttpPost("/goto/none")]
public IActionResult GoToNone() => Ok();
/// POST /goto/room/{room}/{subRoom} — sub-room form
/// from RecNet.Matchmaking.GoToSubRoom
/// (Cpp2IL_ISIL/.../Matchmaking.txt:4682-4702):
/// String.Concat("goto/room/", urlEncode(room), "/", urlEncode(subRoom)).
/// Used by baked AG-Original rooms with multiple scenes (e.g.
/// StuntRunner/TheMainEvent, the playable gauntlet inside
/// the StuntRunner lobby).
///
/// We resolve to the parent room's for
/// the canonical RoomId + LocationReplicationId, then expose the
/// sub-room name via the name field so the client can
/// render the right scene. If we ever build a per-sub-room scene
/// location lookup table, switch
/// to the matching scene's ReplicationId.
[HttpPost("/goto/room/{roomName}/{subRoomName}")]
public async Task> GoToSubRoom(
string roomName, string subRoomName,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
// See GoToRoom: JoinMode 1/2 = fork a fresh public/private
// instance of this sub-room rather than rejoining the shared one.
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null)
{
// Build (don't record yet) — we need to mutate the RoomInstance
// for the sub-room before presence is recorded, otherwise the
// server presence has the lobby's RoomInstance but the client
// thinks it's in the sub-room → heartbeat sees a mismatch and
// the watch logs "presence out-of-sync" / Error -2.
var resp = await BuildResponseAsync(roomName);
// Imported multi-scene rooms (admin Import Room) get one
// RoomSceneEntity row per chapter — prefer that when the
// (room, subRoom) pair matches. Falls back to the hardcoded
// baked-room map for AG-Originals like StuntRunner/TheMainEvent.
//
// Lookup is tolerant: in-game portals don't always pass the
// canonical scene Name. We match in this order:
// 1. exact (case-insensitive — RoomScenes.Name is NOCASE collated)
// 2. EndsWith(subRoomName) — handles portals that omit
// a "Ch0_" / "Chapter1_" prefix
// 3. Contains(subRoomName) — last-resort fuzzy match
// and fall back to the hardcoded location map only when no
// RoomScenes row matches at all.
var allScenes = await db.RoomScenes
.Where(s => s.RoomId == resp.RoomInstance.RoomId)
.Select(s => new { s.OrderIndex, s.Name, s.RoomSceneLocationId, s.DataBlobName })
.ToListAsync();
var dbScene =
allScenes.FirstOrDefault(s =>
string.Equals(s.Name, subRoomName, StringComparison.OrdinalIgnoreCase))
?? allScenes.FirstOrDefault(s =>
s.Name.EndsWith(subRoomName, StringComparison.OrdinalIgnoreCase))
?? allScenes.FirstOrDefault(s =>
s.Name.IndexOf(subRoomName, StringComparison.OrdinalIgnoreCase) >= 0);
long subId;
string? subLocation;
string? subDataBlob;
if (dbScene is not null)
{
logger.LogInformation(
"[goto-subroom] {Room}/{Requested} → matched scene '{MatchedName}' idx={Idx} loc={Loc} blob={Blob}",
roomName, subRoomName, dbScene.Name, dbScene.OrderIndex,
dbScene.RoomSceneLocationId, dbScene.DataBlobName);
subId = dbScene.OrderIndex;
subLocation = dbScene.RoomSceneLocationId;
subDataBlob = dbScene.DataBlobName;
}
else
{
logger.LogWarning(
"[goto-subroom] {Room}/{Requested} — no RoomScenes match (room has {Count} scenes: {Names}). Falling back to hardcoded MapSubRoomToLocationId.",
roomName, subRoomName, allScenes.Count,
string.Join(", ", allScenes.Select(s => s.Name)));
subId = Math.Abs((long)subRoomName.GetHashCode());
subLocation = MapSubRoomToLocationId($"{roomName}/{subRoomName}".ToLowerInvariant());
subDataBlob = null;
}
// Both PhotonRoomId AND RoomInstanceId must differ for the
// watch's transition state machine to fire. Empirical from
// comparing the two failure modes:
// Same photon + same roomInstanceId → blob downloads (because
// dataBlob field changed) but watch never transitions; the
// "Going to X" dialog hangs because nothing the state
// machine tracks signalled "I'm in a different match".
// Different photon + same roomInstanceId → watch doesn't
// even fetch the new dataBlob; it sees roomInstanceId
// unchanged so treats the response as a no-op presence
// update and skips the join flow.
// Folding subId into RoomInstanceId AND forking PhotonRoomId
// gives the watch the two signals it needs: "new instance"
// (roomInstanceId differs → reset state machine) plus "new
// photon match" (photonRoomId differs → leave/rejoin →
// SceneLoaded events that ultimately dismiss the dialog).
ApplySubRoomMutation(resp, subId, subRoomName, subLocation, subDataBlob);
// Private/new-instance mutation goes AFTER sub-room mutation so the
// PhotonRoomId we register includes the _sub{N} suffix as its
// base — invitees coming in via /goto/instance/{id} land in the
// exact same sub-room photon match, not the lobby's.
await ApplyNewInstanceAsync(resp, subRoomId: subId, createPrivateInstance, joinMode);
await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
return Ok(resp);
}
/// Mutate in place so it represents
/// the given sub-scene of its parent room. Both PhotonRoomId AND
/// RoomInstanceId must differ from the lobby's or the watch's
/// transition state machine never fires:
/// • Same photon + same roomInstanceId → the new dataBlob downloads
/// but the watch never transitions; "Going to X" hangs forever.
/// • Different photon + same roomInstanceId → the watch sees the id
/// unchanged, treats the response as a no-op presence update and
/// skips the join flow entirely.
/// Folding subId into RoomInstanceId AND forking PhotonRoomId gives
/// the watch both signals it needs ("new instance" + "new photon
/// match"). dataBlob tells the client which PersistedRoomData to
/// download for the scene (RoomInstance.Deserialize, RVA 0x114DAB0,
/// reads it with a string-with-default reader so empty is legal) —
/// but an empty blob makes the watch fall back to the parent room's
/// lobby blob, so the sub-scene never changes visually. Only override
/// location/blob when we actually resolved one.
private static void ApplySubRoomMutation(
MatchmakingResponseDto resp, long subId, string subRoomName,
string? subLocation, string? subDataBlob, string? subRoomUnityAssetId = null)
{
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
resp.RoomInstance.Name = subRoomName;
resp.RoomInstance.SubRoomId = subId;
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
resp.RoomInstance.SubRoomUnityAssetId = subRoomUnityAssetId ?? string.Empty;
}
/// POST match.*/matchmake/room/{roomId} — the 2023
/// client's primary "join this public room" call
/// (RecNet.Matchmaking, format string "matchmake/room/{0}",
/// same sender HHJFFJEHLOB as the working matchmake/dorm,
/// so it's a POST returning ).
/// It navigates by NUMERIC roomId straight from the room tile, NOT by
/// name like the older /goto/room/{name} path. With no route
/// here every public-room join 404s and the watch bounces the player
/// back to their dorm ("Matchmaking request failed
/// (matchmake/room/N): HTTP Error 404"). We resolve the id to its
/// room and reuse so dorm/private/
/// dataBlob/JoinMode handling stays identical to .
/// GET is accepted too as cheap insurance against a verb mismatch.
[HttpPost("/matchmake/room/{roomId:long}")]
[HttpGet("/matchmake/room/{roomId:long}")]
public async Task> MatchmakeRoom(
long roomId,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null)
{
logger.LogWarning("[matchmake-room] unknown roomId={RoomId} — returning RoomDoesNotExist", roomId);
// 4 = RoomDoesNotExist (CreateModifyRoomStatus); watch shows
// its standard "room not found" toast instead of softlocking.
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
}
var resp = await BuildResponseAsync(room.Name);
await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode);
await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
logger.LogInformation(
"[matchmake-room] player={Player} roomId={RoomId} name={Name} instance={Instance} photon={Photon}",
this.CurrentPlayerId(), roomId, room.Name,
resp.RoomInstance?.RoomInstanceId, resp.RoomInstance?.PhotonRoomId);
return Ok(resp);
}
/// POST match.*/matchmake/room/{roomId}/{subRoomId} —
/// sub-room form (RecNet.Matchmaking "matchmake/room/{0}/{1}").
/// Unlike (which takes a sub-room NAME and
/// fuzzy-matches it) the sub-room arrives here as a numeric
/// , so we resolve the scene
/// row by OrderIndex directly. subRoomId 0 is the lobby/entry scene —
/// the base response already represents it, so we skip the
/// mutation.
[HttpPost("/matchmake/room/{roomId:long}/{subRoomId:long}")]
[HttpGet("/matchmake/room/{roomId:long}/{subRoomId:long}")]
public async Task> MatchmakeSubRoom(
long roomId, long subRoomId,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null)
{
logger.LogWarning(
"[matchmake-room] unknown roomId={RoomId} (sub={Sub}) — returning RoomDoesNotExist",
roomId, subRoomId);
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
}
var resp = await BuildResponseAsync(room.Name);
if (subRoomId != 0)
{
var scene = await db.RoomScenes.AsNoTracking()
.Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId)
.Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName, s.StudioUnityAssetId })
.FirstOrDefaultAsync();
if (scene is not null)
{
logger.LogInformation(
"[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob} unityAsset={UnityAsset}",
roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName, scene.StudioUnityAssetId);
ApplySubRoomMutation(
resp,
subRoomId,
scene.Name,
scene.RoomSceneLocationId,
scene.DataBlobName,
scene.StudioUnityAssetId);
}
else
{
logger.LogWarning(
"[matchmake-subroom] {Room}/{Sub} — no RoomScenes row with that OrderIndex; " +
"forking ids onto the lobby scene so the watch still transitions",
roomId, subRoomId);
// Reuse the lobby location/blob (sub-scene won't differ
// visually) but still fold subId in so the watch leaves
// the current match instead of softlocking.
ApplySubRoomMutation(resp, subRoomId, resp.RoomInstance.Name, null, null);
}
}
await ApplyNewInstanceAsync(resp, subRoomId: subRoomId, createPrivateInstance, joinMode);
await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
return Ok(resp);
}
/// Per-sub-room scene location overrides for baked
/// AG-Original rooms with multiple scenes. Each entry maps a
/// "{room}/{subRoom}" key (lowercase) to the
/// RoomSceneLocationId GUID baked into the client's
/// AGRoomRuntimeConfig.Locations table. Without an entry the
/// parent room's location is reused (lobby loads, door opens
/// but transition no-ops).
///
/// Add entries here as you confirm each sub-scene's GUID — the
/// client's AGRoomSettings.TryGetRoomSceneLocationById
/// rejects unknown GUIDs with "RecNet game session contains
/// unknown scene location ID" and refuses to load.
/// Sub-room scene GUIDs extracted from the bundled
/// resources.assets AGRoomRuntimeConfig.Rooms[].Scenes[]
/// using tools/extract-rooms-binary.py. Each entry maps
/// "{room}/{subRoom}" (lowercase) to the target
/// RoomSceneLocationId GUID. Missing sub-rooms reuse the
/// parent room's lobby location (door opens but transition no-ops).
private static string? MapSubRoomToLocationId(string subKey) => subKey switch
{
// ── StuntRunner ──
// Lobby is the implicit Scene[0]; TheMainEvent is the
// playable gauntlet behind the door.
"stuntrunner/themainevent" => "3a636bd2-f896-424c-9225-c184522c0d87",
// ── Paintball (River map + variants) ──
// The Paintball Room has nested sub-rooms for each map; the
// lobby acts as a hub and players pick a map from a board.
"paintball/river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
"paintball/homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7",
"paintball/quarry" => "ff4c6427-7079-4f59-b22a-69b089420827",
"paintball/clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161",
"paintball/spillway" => "58763055-2dfb-4814-80b8-16fac5c85709",
"paintballvr/river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
"paintballvr/homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7",
"paintballvr/quarry" => "ff4c6427-7079-4f59-b22a-69b089420827",
"paintballvr/clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161",
"paintballvr/spillway" => "58763055-2dfb-4814-80b8-16fac5c85709",
// ── LaserTag ──
"lasertag/cyberjunkcity" => "9d6456ce-6264-48b4-808d-2d96b3d91038",
_ => null,
};
[HttpPost("/goto/event/{eventId:long}")]
// 2023 rename goto/event → matchmake/event (RecNet.Matchmaking HHDMGIMLBKF).
[HttpPost("/matchmake/event/{eventId:long}")]
public async Task> GoToEvent(long eventId)
{
// Matchmake the caller into the event's host room. Falls back to
// the dorm if the event is unknown or has no room set.
var roomId = await db.PlayerEvents.AsNoTracking()
.Where(e => e.Id == eventId)
.Select(e => (long?)e.RoomId)
.FirstOrDefaultAsync();
string? roomName = null;
if (roomId is long rid && rid > 0)
roomName = await db.Rooms.AsNoTracking()
.Where(r => r.Id == rid)
.Select(r => r.Name)
.FirstOrDefaultAsync();
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);
}
[HttpPost("/goto/club/{clubId:long}")]
// 2023 rename goto/club → matchmake/club (RecNet.Matchmaking KPAIGCCHIBN).
[HttpPost("/matchmake/club/{clubId:long}")]
public async Task> GoToClub(long clubId)
{
var club = await db.Clubs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == clubId);
if (club is null)
{
return Ok(new MatchmakingResponseDto
{
ErrorCode = 4,
RoomInstance = new RoomInstanceDto(),
});
}
// Land in the club's clubhouse room when it has one set; otherwise
// fall back to the Rec Center. Either way the response carries the
// ClubId so the watch's club UI knows which club it matchmade for.
string roomName = "RecCenter";
if (club.ClubhouseRoomId is long clubhouseId && clubhouseId > 0)
{
var name = await db.Rooms.AsNoTracking()
.Where(r => r.Id == clubhouseId)
.Select(r => r.Name)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(name)) roomName = name;
}
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);
}
[HttpPost("/goto/code")]
[HttpPost("/goto/code/{code}")]
// 2023 sends the room code as the second segment behind the room id
// (RecNet.Matchmaking CPINEGCODMD → "matchmake/code/{roomId}/{code}").
// The code alone resolves the private instance, so the roomId is just
// routing context we don't need — resolve by code either way.
[HttpPost("/matchmake/code/{roomId:long}/{code}")]
public async Task> GoToCode(string? code = null, long roomId = 0)
{
code ??= Request.HasFormContentType
? (await Request.ReadFormAsync())["code"].FirstOrDefault()
: Request.Query["code"].FirstOrDefault();
code = RoomCodeService.Normalize(code);
if (string.IsNullOrWhiteSpace(code))
{
return Ok(new MatchmakingResponseDto
{
ErrorCode = 40,
RoomInstance = new RoomInstanceDto(),
});
}
var privateIds = await db.PrivateInstances.AsNoTracking()
.Select(p => p.Id)
.ToListAsync();
var instanceId = privateIds.FirstOrDefault(id => RoomCodeService.Generate(id) == code);
if (instanceId == 0)
{
return Ok(new MatchmakingResponseDto
{
ErrorCode = 40,
RoomInstance = new RoomInstanceDto(),
});
}
var resp = await BuildPrivateInstanceJoinResponseAsync(instanceId);
await RecordResponseAsync(resp);
return Ok(resp);
}
[HttpPost("/goto/playlist")]
[HttpPost("/goto/playlist/{playlist}")]
public async Task> GoToPlaylist(string? playlist = null)
{
playlist ??= Request.Query["playlist"].FirstOrDefault()
?? Request.Query["playlistId"].FirstOrDefault();
var value = (playlist ?? string.Empty).Trim();
var roomName = "RecCenter";
if (!string.IsNullOrWhiteSpace(value))
{
var lowered = value.ToLowerInvariant();
var room = await db.Rooms.AsNoTracking()
.Where(r => r.Accessibility == 2
&& (r.Name.ToLower().Contains(lowered)
|| r.TagsCsv.ToLower().Contains(lowered)))
.OrderByDescending(r => r.HotScore)
.FirstOrDefaultAsync();
if (room is not null) roomName = room.Name;
}
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);
}
/// POST /goto/instance/{roomInstanceId} — invite-
/// accept route. The watch posts here when joining a private
/// match via a Message-attached invite. We resolve the instance
/// id to the registered
/// and return a RoomInstance pinned to the inviter's PhotonRoomId
/// so both players land in the same Photon match. Falls back to
/// dorm if the instance was forgotten (server restart, owner
/// already left and nobody re-created it).
[HttpPost("/goto/instance/{roomInstanceId:long}")]
// The 2023 client renamed the whole matchmaking surface from goto/* to
// matchmake/* (verified 2023.03.21 il2cpp dump — RecNet.Matchmaking
// ANOAAIHGHMG builds "matchmake/instance/{0}"). Same handler, both
// mounts, so December and 2023 clients each hit the path they build.
[HttpPost("/matchmake/instance/{roomInstanceId:long}")]
public async Task> GoToInstance(long roomInstanceId)
{
var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId);
await RecordResponseAsync(resp);
return Ok(resp);
}
/// POST matchmake/chatinvite/{accountId}/{roomInstanceId}
/// — the 2023 "pull into my match" path. When a party owner invites
/// someone over chat (PlayerParty.AcceptChatPartyInvite →
/// RecNet.Matchmaking chatinvite, verified in the 2023.03.21 il2cpp
/// dump), the invitee's watch posts here to matchmake into the owner's
/// instance. The two path params are the inviter's account id and the
/// room instance id in an order the client doesn't document; a private
/// instance id is always ≥ 2^24 while account ids are small sequential,
/// so we take the param that resolves to a registered instance (falling
/// back to the larger value). Unlike the message-based
/// POST /invite flow, a chat party invite writes no invitee row,
/// so we add the caller to the allow-list here before resolving the
/// join — the owner explicitly pulled them in.
[HttpPost("/matchmake/chatinvite/{p0:long}/{p1:long}")]
public async Task> MatchmakeChatInvite(long p0, long p1)
{
var roomInstanceId = await ResolveChatInviteInstanceAsync(p0, p1);
if (this.CurrentPlayerId() is long callerId && roomInstanceId > 0)
await privateInstances.InviteAsync(roomInstanceId, callerId);
var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId);
await RecordResponseAsync(resp);
logger.LogInformation(
"[matchmake-chatinvite] caller={Caller} p0={P0} p1={P1} → instance={Instance} error={Error}",
this.CurrentPlayerId(), p0, p1, roomInstanceId, resp.ErrorCode);
return Ok(resp);
}
/// Pick the room-instance param out of a chatinvite's two path
/// segments. Prefers whichever resolves to a registered private
/// instance; if neither does (e.g. a public-room party pull whose id is
/// synthetic), falls back to the larger value since instance ids dwarf
/// account ids.
private async Task ResolveChatInviteInstanceAsync(long p0, long p1)
{
if (await privateInstances.GetAsync(p0) is not null) return p0;
if (await privateInstances.GetAsync(p1) is not null) return p1;
return Math.Max(p0, p1);
}
[HttpPost("/goto/invite/{inviteId:long}")]
// 2023 rename of goto/invite → matchmake/invite (RecNet.Matchmaking
// OIEDPBLNMOP). {inviteId} is the invite message id, same as December.
[HttpPost("/matchmake/invite/{inviteId:long}")]
public async Task> GoToInvite(long inviteId)
{
var callerId = this.CurrentPlayerId();
if (callerId is null)
{
var unauth = await BuildResponseAsync("DormRoom");
await RecordResponseAsync(unauth);
return Ok(unauth);
}
var invite = await db.Messages.FirstOrDefaultAsync(m =>
m.Id == inviteId
&& m.RecipientPlayerId == callerId.Value
&& (m.Type == 6 || m.Type == 7 || m.Type == -6 || m.Type == -7));
long? roomInstanceId = null;
if (invite is not null && TryParseRoomInstanceId(invite.Body, out var parsed))
roomInstanceId = parsed;
// The 2020 watch's accept-invite flow fires
// POST /api/messages/v3/delete AND POST /goto/invite/{id} in
// parallel — verified in the live server log:
// 09:53:47.161Z DELETE messageIds=[60]
// 09:53:47.245Z /goto/invite/60 → [goto-invite] invite 60 not found
// Current deletes tombstone invite rows so this should only
// be needed for invite rows that were hard-deleted by older
// builds. The (instance, player) → LatestInviteMessageId
// mapping that SendInvite writes onto the
// PrivateInstanceInviteeEntity survives those old deletions
// and lets us recover roomInstanceId here.
if (roomInstanceId is null)
{
var inviteeRoomInstance = await db.PrivateInstanceInvitees
.Where(i => i.PlayerId == callerId.Value && i.LatestInviteMessageId == inviteId)
.Select(i => (long?)i.PrivateInstanceId)
.FirstOrDefaultAsync();
if (inviteeRoomInstance is long inst)
{
roomInstanceId = inst;
logger.LogInformation(
"[goto-invite] message {InviteId} missing (likely raced with DELETE); resolved roomInstance={Instance} via invitee fallback for caller {Caller}",
inviteId, inst, callerId);
}
}
if (roomInstanceId is null)
{
logger.LogWarning(
"[goto-invite] invite {InviteId} not found and no invitee fallback for caller {Caller}",
inviteId, callerId);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 40, // RoomInviteExpired
RoomInstance = new RoomInstanceDto(),
});
}
// Mark the message read. The watch's parallel DELETE may have
// wiped the row between our initial read and this save —
// swallow the resulting DbUpdateConcurrencyException since the
// accept has already succeeded at this point.
if (invite is not null && invite.ReadAt is null)
{
invite.ReadAt = DateTime.UtcNow;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
db.Entry(invite).State = EntityState.Detached;
}
}
// Two routes:
// PRIVATE instance: roomInstanceId resolves to a registered
// PrivateInstance → build a join response pinned to its
// photonRoomId + invite-list enforcement.
//
// PUBLIC room: no PrivateInstance for that id (sender was in
// a public room / a dorm / etc.). Use the message's RoomId
// to look up the room name and route via the standard public
// matchmaking path so the recipient lands in the same room
// (different instance is fine — public rooms aren't pinned
// to one Photon match).
var isPrivate = await privateInstances.GetAsync(roomInstanceId.Value) is not null;
MatchmakingResponseDto resp;
if (isPrivate)
{
resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId.Value);
}
else if (invite?.RoomId is long publicRoomId && publicRoomId > 0)
{
var roomName = await db.Rooms
.Where(r => r.Id == publicRoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync();
if (string.IsNullOrWhiteSpace(roomName))
{
logger.LogWarning(
"[goto-invite] invite {InviteId} → no PrivateInstance and Message.RoomId={RoomId} doesn't resolve to a Rooms row; routing to dorm",
inviteId, publicRoomId);
resp = await BuildResponseAsync("DormRoom");
}
else
{
logger.LogInformation(
"[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
{
// No private instance and the message has no RoomId. This
// shouldn't happen with the current SendInvite (it always
// stamps RoomId), but legacy rows from before the public-
// room invite fix may not have one. Fall back to dorm.
logger.LogWarning(
"[goto-invite] invite {InviteId} → no PrivateInstance and no Message.RoomId; routing to dorm",
inviteId);
resp = await BuildResponseAsync("DormRoom");
}
await RecordResponseAsync(resp);
return Ok(resp);
}
private async Task BuildPrivateInstanceJoinResponseAsync(long roomInstanceId)
{
var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is null)
{
logger.LogWarning(
"[goto-instance] {Id} → no registered private instance; routing to dorm",
roomInstanceId);
return await BuildResponseAsync("DormRoom");
}
// Enforce invite-list membership. Owner is always allowed (so
// they can rejoin their own match after a disconnect). Anyone
// else needs to have been invited via POST /invite. We refuse
// with errorCode != 0 so the watch surfaces a "join failed"
// toast instead of silently routing to the wrong photon room.
if (this.CurrentPlayerId() is long callerId
&& !await privateInstances.CanJoinAsync(roomInstanceId, callerId))
{
logger.LogWarning(
"[goto-instance] {Id} → caller {Caller} is not on the invite list (owner={Owner}); rejecting",
roomInstanceId, callerId, inst.OwnerPlayerId);
// MatchmakingErrorCode 26 = RoomInstanceIsPrivate — the
// dedicated "you're not invited" error from the
// RecNet.Matchmaking+MatchmakingErrorCode enum (verified in
// Cpp2IL_CS/.../Matchmaking.cs line 166). Surfaces a clearer
// "this is a private match" message on the watch than
// 9 = RoomNotJoinable does.
return new MatchmakingResponseDto
{
ErrorCode = 26,
RoomInstance = new RoomInstanceDto(),
};
}
return new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = new RoomInstanceDto
{
RoomInstanceId = inst.InstanceId,
RoomId = inst.RoomId,
SubRoomId = inst.SubRoomId,
Location = inst.Location,
PhotonRegionId = inst.PhotonRegion,
PhotonRoomId = inst.PhotonRoomId,
Name = inst.Name,
MaxCapacity = inst.MaxCapacity,
IsPrivate = true,
IsInProgress = true,
DataBlob = inst.DataBlob,
SubRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(inst.RoomId, inst.SubRoomId),
},
};
}
// Invite-message Body can be any of three shapes depending on how /
// when it was written:
// 1. Modern (current): JSON {"inviteId":N,"name":"…","roomInstanceId":N}
// — extract roomInstanceId from the JSON property.
// 2. Legacy (pre-V2 fix): bare integer = roomInstanceId.
// 3. Ancient (pre-Type-column): text "Invite to private match
// (instance N)" — parse the trailing integer out of the literal.
// Honour all three so old rows in the DB still resolve.
private static bool TryParseRoomInstanceId(string body, out long instanceId)
{
instanceId = 0;
if (string.IsNullOrWhiteSpace(body)) return false;
if (body.TrimStart().StartsWith('{'))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("roomInstanceId", out var v))
{
// String form first — the SendInvite serializer now
// emits roomInstanceId as a quoted string because
// LitJson on the watch fails to parse a JSON object
// that contains a 64-bit numeric literal larger than
// int32 (returns null from the dict cast → NRE in
// Util.GetKey downstream). Number form stays
// supported for any old rows that pre-dated the
// stringify fix.
if (v.ValueKind == System.Text.Json.JsonValueKind.String
&& long.TryParse(v.GetString(), System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var strInst))
{ instanceId = strInst; return true; }
if (v.ValueKind == System.Text.Json.JsonValueKind.Number
&& v.TryGetInt64(out var numInst))
{ instanceId = numInst; return true; }
}
}
catch { /* fall through to legacy formats */ }
}
if (long.TryParse(body, out instanceId)) return true;
var open = body.IndexOf("instance ", StringComparison.Ordinal);
if (open < 0) return false;
var tail = body[(open + "instance ".Length)..].TrimEnd(')', '.', ' ');
return long.TryParse(tail, out instanceId);
}
/// POST /goto/player/{playerId} — join the room
/// the target player is currently in.
///
/// For PUBLIC rooms: mirror the target's live RoomInstance. The
/// 2020.12 client reconciles joined players against the exact
/// roomInstanceId/photonRoomId pair it got from matchmaking; rebuilding
/// a fresh instance here can leave the caller in a different Photon
/// shard than the target and null-ref during the follow-join callback.
///
/// For PRIVATE rooms: this route must NOT bypass the invite list.
/// Earlier we mirrored the target's RoomInstance verbatim under
/// the assumption "Go to player only shows up for friends" —
/// that's wrong: friend ≠ invited, and the owner picked Private
/// specifically to control who joins. Caller must be the owner
/// or on the invite list per ;
/// otherwise we refuse with errorCode=9 (RoomNotJoinable).
[HttpPost("/goto/player/{playerId:long}")]
// 2023 rename goto/player → matchmake/player (RecNet.Matchmaking
// FJAFOJHHKPO). Follow a friend into their current room/instance.
[HttpPost("/matchmake/player/{playerId:long}")]
public async Task> GoToPlayer(long playerId)
{
var theirRoom = presence.GetRoom(playerId);
MatchmakingResponseDto resp;
if (theirRoom is null)
{
resp = await BuildResponseAsync("DormRoom");
}
else if (theirRoom.IsPrivate)
{
// RoomInstanceId IS the registry key for private matches —
// generated by PrivateInstanceService.Register. Caller must
// be invited (or owner) to land in the same Photon room.
var callerId = this.CurrentPlayerId();
if (callerId is null
|| !await privateInstances.CanJoinAsync(theirRoom.RoomInstanceId, callerId.Value))
{
logger.LogWarning(
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
callerId, playerId, theirRoom.RoomInstanceId);
// Tell the OWNER someone tried to follow them in, as the
// client's own "wants to join" message (RequestGameInvite,
// from the caller), so they can send an invite. At most once
// per requester, owner and instance per cooldown, so retrying
// doesn't fill the owner's inbox.
if (callerId is long requester
&& JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 26, // RoomInstanceIsPrivate
RoomInstance = new RoomInstanceDto(),
});
}
// Allowed — mirror the target's RoomInstance so we land
// in the same Photon match.
resp = new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = CloneRoomInstance(theirRoom),
};
}
else
{
// Public follow-joins still need to land in the target's
// specific live instance, not just the same room name. This
// preserves sub-room and Photon shard identity for PUN's
// player-enter reconciliation.
resp = new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = CloneRoomInstance(theirRoom),
};
}
await RecordResponseAsync(resp);
return Ok(resp);
}
private static RoomInstanceDto CloneRoomInstance(RoomInstanceDto room) => new()
{
RoomInstanceId = room.RoomInstanceId,
RoomId = room.RoomId,
SubRoomId = room.SubRoomId,
Location = room.Location,
PhotonRegionId = room.PhotonRegionId,
PhotonRoomId = room.PhotonRoomId,
MaxCapacity = room.MaxCapacity,
IsFull = room.IsFull,
IsPrivate = room.IsPrivate,
IsInProgress = room.IsInProgress,
DataBlob = room.DataBlob,
SubRoomUnityAssetId = room.SubRoomUnityAssetId,
EventId = room.EventId,
Name = room.Name,
RoomInstanceType = room.RoomInstanceType,
ClubId = room.ClubId,
RoomCode = room.RoomCode,
EncryptVoiceChat = room.EncryptVoiceChat,
};
/// Record the just-built response against the bearer-token
/// player so the next heartbeat reflects the room they joined.
/// Anonymous /goto calls (no JWT) skip recording — the heartbeat is
/// authenticated, so a match only happens when the same JWT shows
/// up there. Without this, heartbeats return roomInstance:null and
/// the watch trips its "presence out-of-sync" detector → "Error: -2".
/// Caller is responsible for any sub-room or private-instance
/// mutations BEFORE handing the response in here.
private async Task RecordResponseAsync(MatchmakingResponseDto response)
{
if (this.CurrentPlayerId() is not long playerId) return;
if (response.RoomInstance is null) return;
var targetRoom = response.RoomInstance;
var currentRoom = presence.GetRoom(playerId);
var deferPresenceCommit = ShouldDeferPresenceCommit(currentRoom, targetRoom);
joinTimeouts.MarkPending(playerId, targetRoom, deferPresenceCommit);
if (deferPresenceCommit)
{
logger.LogInformation(
"[goto] deferred presence commit player={PlayerId} room={RoomId} subRoom={SubRoomId} fromInstance={FromInstance} toInstance={ToInstance} fromPhoton={FromPhoton} toPhoton={ToPhoton}",
playerId,
targetRoom.RoomId,
targetRoom.SubRoomId,
currentRoom?.RoomInstanceId,
targetRoom.RoomInstanceId,
currentRoom?.PhotonRoomId,
targetRoom.PhotonRoomId);
return;
}
presence.SetRoom(playerId, targetRoom);
presence.MarkActive(playerId);
// Real visit tracking: upsert the per-(room, player) row so
// we get distinct VisitorCount (unique players ever) AND the
// gross VisitCount (every join, including rejoins). Skip the
// canonical dorm (RoomId=1) so per-day boots don't skew it.
if (targetRoom.RoomId != 1)
await RecordVisitAsync(playerId, targetRoom.RoomId);
// Push a presence-update notification to every accepted-friend
// so their watch's "friends" list shows the new room. Cheap
// query — Relationships is small, and we only fan out to the
// actually-connected friends.
await NotifyFriendsOfMoveAsync(playerId, targetRoom);
// NOTE: we INTENTIONALLY do NOT self-push SubscriptionUpdateRoom
// from /goto. The watch's Rooms.OnSubscriptionUpdateRoom
// (Cpp2IL_ISIL/.../RecNet/Rooms.txt:2401-2496) is gated on
// (Rooms.cachedRoomId == roomId && Rooms.cachedSubRoomId == subRoomId):
// the FIRST push for a (room, subRoom) tuple does the fetch +
// set_LocalRoomDetails; subsequent pushes for the same tuple
// exit early at line 055, NEVER firing the
// RoomDetailsUpdated event. The save coroutine's
// RoomDetailsUpdatedCallback is registered to that event
// (RoomPersistenceManager+DisplayClass174_0:14-48) and only
// sets the roomDetailsUpdated flag when the event fires —
// which is the ONLY thing that flips the b__4 predicate true
// and dismisses the save spinner. Pre-pushing here primes the
// cached tuple so the save's own push gets deduplicated → no
// event → spinner wedges forever. Let the save's push be the
// first one for any (room, subRoom).
}
/// Party-follow. When the leader's /goto carries
/// AdditionalPlayerIds (party members the watch wants to bring
/// along — added as repeated form fields by RecNet.Matchmaking,
/// Matchmaking.txt:5798-5806), drop a GameInviteV2 (Type 6) into each
/// member's inbox pointing at the leader's freshly-built RoomInstance.
/// Mirrors : registers each
/// member on the private instance (so /goto/instance|invite accepts
/// them) and pushes a MessageReceived notification carrying
/// {inviteId,name,roomInstanceId} so their watch's GameInviteController
/// follows via /goto/invite/{inviteId}. Without this the leader travels
/// alone and the party silently stays behind — the reported bug. We
/// notify ONLY the members, not the leader (unlike SendInvite's
/// both-sides chat sync): the leader doesn't want an invite to the room
/// they're already entering.
private async Task NotifyAdditionalPlayersAsync(
List? additionalPlayerIds, MatchmakingResponseDto resp)
{
if (additionalPlayerIds is null || additionalPlayerIds.Count == 0) return;
if (resp.RoomInstance is null) return;
if (this.CurrentPlayerId() is not long leaderId) return;
var targets = additionalPlayerIds
.Where(id => id > 0 && id != leaderId)
.Distinct()
.ToList();
if (targets.Count == 0) return;
var instance = resp.RoomInstance;
var roomName = !string.IsNullOrWhiteSpace(instance.Name) ? instance.Name : "Room";
foreach (var target in targets)
{
// Type 6 = RecNet.Message.MessageType.GameInviteV2 — the watch's
// GameInviteController turns this into goto/invite/{inviteId}.
// First save allocates the Id that BECOMES the inviteId; the
// second backfills the JSON Body once we know it (EF identity
// columns only populate on SaveChanges).
var entry = new MessageEntity
{
SenderPlayerId = leaderId,
RecipientPlayerId = target,
Body = string.Empty,
Type = 6,
RoomId = instance.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
// Private instances gate joins on the invitee list; register the
// member so /goto/instance|invite lets them in. Public rooms have
// no registered instance — GoToInvite falls back to the message's
// RoomId, so no registration is needed there.
if (instance.IsPrivate)
await privateInstances.InviteAsync(instance.RoomInstanceId, target, messageId: entry.Id);
// roomInstanceId MUST be a JSON STRING — LitJson NREs on a >int32
// long in the dict cast and aborts the whole Message.Deserialize
// (see the detailed note in MessagesController.SendInvite).
entry.Body = System.Text.Json.JsonSerializer.Serialize(new
{
inviteId = entry.Id,
name = roomName,
roomInstanceId = instance.RoomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture),
});
await db.SaveChangesAsync();
var dto = new MessagesController.MessageDto(
Id: entry.Id,
FromPlayerId: entry.SenderPlayerId,
SentTime: entry.SentAt,
Type: entry.Type,
Data: entry.Body,
RoomId: entry.RoomId,
PlayerEventId: null);
await notifications.NotifyAsync(target, PushNotificationId.MessageReceived, dto);
}
logger.LogInformation(
"[goto-party] leader={Leader} room={Room}/\"{Name}\" instance={Instance} isPrivate={Priv} → invited {Count} member(s): {Members}",
leaderId, instance.RoomId, roomName, instance.RoomInstanceId,
instance.IsPrivate, targets.Count, string.Join(",", targets));
}
private static bool ShouldDeferPresenceCommit(RoomInstanceDto? currentRoom, RoomInstanceDto targetRoom) =>
currentRoom is not null
&& targetRoom.IsPrivate
&& currentRoom.RoomId == targetRoom.RoomId
&& currentRoom.SubRoomId == targetRoom.SubRoomId
&& (currentRoom.RoomInstanceId != targetRoom.RoomInstanceId
|| !string.Equals(currentRoom.PhotonRoomId, targetRoom.PhotonRoomId, StringComparison.OrdinalIgnoreCase));
/// Mutate the response so it represents a fresh invite-only
/// match: forks the PhotonRoomId with a per-(player, nonce) suffix
/// so randos can't matchmake into it, sets isPrivate=true, and
/// registers the instance in
/// so invitees coming in via /goto/instance/{id} can find this
/// exact Photon match.
private async Task ApplyPrivateInstanceAsync(MatchmakingResponseDto resp, long subRoomId)
{
if (this.CurrentPlayerId() is not long ownerId)
{
// Anonymous private-instance creation makes no sense — the
// owner is the keying signal for the photon room name and
// for invite resolution. Fall through with isPrivate flag
// only; the watch won't be able to invite anyone but at
// least won't mismatch the requested form value.
resp.RoomInstance.IsPrivate = true;
return;
}
var inst = await privateInstances.RegisterAsync(
roomId: resp.RoomInstance.RoomId,
subRoomId: subRoomId,
ownerPlayerId: ownerId,
baseName: resp.RoomInstance.Name.ToLowerInvariant(),
location: resp.RoomInstance.Location,
dataBlob: resp.RoomInstance.DataBlob,
photonRegion: resp.RoomInstance.PhotonRegionId,
maxCapacity: resp.RoomInstance.MaxCapacity);
resp.RoomInstance.RoomInstanceId = inst.InstanceId;
resp.RoomInstance.PhotonRoomId = inst.PhotonRoomId;
resp.RoomInstance.IsPrivate = true;
logger.LogInformation(
"[goto-private] room={Room} sub={Sub} owner={Owner} → instId={Id} photon={Photon}",
resp.RoomInstance.RoomId, subRoomId, ownerId, inst.InstanceId, inst.PhotonRoomId);
}
/// Apply the "make a new instance" mutation to a freshly
/// built matchmaking response based on the watch's JoinMode (and the
/// legacy CreatePrivateInstance flag). Does nothing for ordinary
/// PublicMatchmaking joins (JoinMode=0) — those keep the shared,
/// deterministic instance that lets randos matchmake together.
/// • JoinMode=2 / CreatePrivateInstance → fresh invite-gated
/// instance (registered + owned, isPrivate=true).
/// • JoinMode=1 → fresh PUBLIC instance (unique id+photon room,
/// isPrivate=false, not persisted — lives only while populated).
/// Both forks change roomInstanceId AND photonRoomId, which is what
/// the watch's transition state machine needs to actually leave the
/// current match instead of softlocking on "Going to <room>".
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);
}
else if (joinMode == 1 /* PublicNewInstance */)
{
ApplyNewPublicInstance(resp);
}
}
/// Fork a fresh PUBLIC instance of the room the watch just
/// asked for (JoinMode=PublicNewInstance). Unlike a private instance
/// this isn't persisted or invite-gated — it's discoverable through
/// live presence (friends joining
/// via /goto/player) and evaporates once empty. The id is packed the
/// same way as
/// (nonce in the low 24 bits, roomId/subRoomId in the high bits, kept
/// inside int63 for LitJson) so it can't collide with the shared
/// roomId*100000 instance the player is forking away from. The photon
/// room id gets a _new{nonce} suffix on the existing base so it
/// differs from the shared lobby's room.
private void ApplyNewPublicInstance(MatchmakingResponseDto resp)
{
var nonce = Random.Shared.Next(0x10000, 0xFFFFFF);
var instanceId = (long)(((ulong)(uint)nonce & 0xFFFFFFul)
^ ((ulong)resp.RoomInstance.RoomId << 24)
^ ((ulong)resp.RoomInstance.SubRoomId << 40));
if (instanceId < 0) instanceId = -instanceId;
resp.RoomInstance.RoomInstanceId = instanceId;
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_new{nonce:x6}";
resp.RoomInstance.IsPrivate = false;
logger.LogInformation(
"[goto-newpublic] room={Room} sub={Sub} → instId={Id} photon={Photon}",
resp.RoomInstance.RoomId, resp.RoomInstance.SubRoomId,
instanceId, resp.RoomInstance.PhotonRoomId);
}
/// Upsert the (room, player)
/// row for this join. New rows bump
/// (unique-visitor metric);
/// every join — new or repeat — bumps
/// + the per-player counter.
private async Task RecordVisitAsync(long playerId, long roomId)
{
var visit = await db.RoomVisits
.FirstOrDefaultAsync(v => v.RoomId == roomId && v.PlayerId == playerId);
var isFirst = visit is null;
if (isFirst)
{
visit = new DorkNet.Server.Data.Entities.RoomVisitEntity
{
RoomId = roomId,
PlayerId = playerId,
FirstVisitAt = DateTime.UtcNow,
};
db.RoomVisits.Add(visit);
}
visit!.LastVisitAt = DateTime.UtcNow;
visit.VisitCount += 1;
// Counter bumps go through ExecuteUpdate to avoid re-reading
// the room — atomic increment under concurrent /goto calls.
// Also bumps HotScore so the "Hot" tab self-reorders as
// players actually use rooms. Weight: each first-time visit
// adds 2.0, each repeat adds 0.25 (revisits matter less than
// new players). Cheers also feed in via PlayerStateController
// / MissingEndpointsController by the same factor.
if (isFirst)
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
.SetProperty(r => r.VisitorCount, r => r.VisitorCount + 1)
.SetProperty(r => r.HotScore, r => r.HotScore + 2.0));
}
else
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
.SetProperty(r => r.HotScore, r => r.HotScore + 0.25));
}
await db.SaveChangesAsync();
}
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
{
// Honors the global-friends toggle (every other player is a "friend"
// when it is on). Goes through the shared fan-out so the frame
// carries the player's real status visibility and the same
// online/room state every other presence transition reports.
logger.LogInformation(
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName}",
playerId, room.RoomId, room.Name);
await presenceFanout.PublishCurrentAsync(playerId);
}
private async Task BuildResponseAsync(string roomName)
{
// Try to resolve from the Rooms DB first — that gives us the real
// RoomId (so subsequent /api/rooms/v4/details/{id} hits the same
// entity) and the canonical LocationReplicationId. Fall back to the
// baked-in DormRoom mapping for unknown names.
long roomId;
string locationId;
var maxCapacity = 8; // admin-adjustable per-room cap; 8 = default/fallback
if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase))
{
// Each player has their own dorm row (created at signup,
// lazy-created here for legacy accounts that pre-date the
// per-player dorm work). Resolve to the caller's personal
// dorm so /api/rooms/v4/details/{id} hits a real Rooms.id
// and storage.rec.net/upload's room lookup succeeds.
if (this.CurrentPlayerId() is long callerPid)
{
var personal = await rooms.EnsurePersonalDormAsync(callerPid);
roomId = personal.Id;
locationId = personal.LocationReplicationId;
maxCapacity = personal.MaxCapacity;
}
else
{
// Anonymous /goto/room/DormRoom shouldn't happen in the
// 2020 watch (the watch always authenticates first), but
// fall back to the canonical scene id for safety.
roomId = 1;
locationId = "76d98498-60a1-430c-ab76-b54a29b7a163";
}
}
else
{
var room = await rooms.GetByNameAsync(roomName);
if (room is not null)
{
roomId = room.Id;
locationId = room.LocationReplicationId;
maxCapacity = room.MaxCapacity;
}
else
{
roomId = MapRoomNameToId(roomName);
locationId = MapRoomNameToReplicationId(roomName);
}
}
var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
// DormRoom is per-player (each account has its own dorm); Rec Center
// and other public rooms are shared by name. Including the player's
// id in the dorm room name avoids the case where rebuilding the DB
// gives a fresh accountId but Photon still considers the old
// "recroom_dormroom_1" room locked from the previous account's
// session — manifested as a successful /goto but no actual scene
// load (black screen).
var isDorm = roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase);
var dormOwnerId = this.CurrentPlayerId();
var dormInstanceName = isDorm && dormOwnerId is long ownerForDisplay
? await BuildDormInstanceNameAsync(ownerForDisplay)
: roomName;
var photonRoom = (isDorm && dormOwnerId is long ownerForName)
? $"^dormroom_p{ownerForName}"
: $"^{roomName.ToLowerInvariant()}_{roomId}";
var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId);
var subRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(roomId, 0);
var resp = new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = new RoomInstanceDto
{
// Deterministic: same name → same instance id, every time.
RoomInstanceId = roomId * 100_000L,
RoomId = roomId,
SubRoomId = 0,
// location is a STRING replicationId, parsed via
// RoomSceneLocations.TryGetRoomSceneLocationById (RVA 0x10F57D0).
// The value is matched against AGRoomRuntimeConfig.Room.ReplicationId
// entries baked into resources.assets. Sending an int (or wrong
// string) makes RoomInstance.Deserialize log "RecNet game session
// contains unknown scene location ID" and the scene never loads.
Location = locationId,
// photonRegionId is a STRING that maps to CloudRegionCode enum
// (eu, us, asia, jp, none, au, usw, sa, cae, kr, in, ru, rue).
// Lowercase enum names — that's the wire format Photon uses too.
PhotonRegionId = photonRegion,
PhotonRoomId = photonRoom,
Name = dormInstanceName,
MaxCapacity = maxCapacity,
IsFull = false,
IsPrivate = false,
IsInProgress = true,
DataBlob = dataBlob,
SubRoomUnityAssetId = subRoomUnityAssetId,
},
};
logger.LogInformation(
"[goto] room instance room={RoomId} name={RoomName} player={PlayerId} dataBlob={DataBlob}",
roomId, roomName, dormOwnerId, dataBlob);
// Dorms are always private — the per-player photon-room name
// (^dormroom_p{owner}) prevents matchmaking, but without
// IsPrivate=true the /goto/player path mirrors the room
// verbatim and lets uninvited friends drop straight in. Stamp
// the response with a deterministic-id PrivateInstance owned
// by the dorm's owner so CanJoinAsync gates non-owners.
if (isDorm && dormOwnerId is long ownerPid)
{
var dorm = await privateInstances.EnsureForDormAsync(
ownerPlayerId: ownerPid,
roomId: resp.RoomInstance.RoomId,
subRoomId: 0,
baseName: dormInstanceName,
location: resp.RoomInstance.Location,
dataBlob: resp.RoomInstance.DataBlob,
photonRegion: resp.RoomInstance.PhotonRegionId,
maxCapacity: resp.RoomInstance.MaxCapacity,
photonRoomId: resp.RoomInstance.PhotonRoomId);
resp.RoomInstance.RoomInstanceId = dorm.InstanceId;
resp.RoomInstance.IsPrivate = true;
}
return resp;
}
private async Task BuildDormInstanceNameAsync(long ownerPlayerId)
{
var player = await db.Players.AsNoTracking()
.Where(p => p.Id == ownerPlayerId)
.Select(p => new { p.DisplayName, p.Username })
.FirstOrDefaultAsync();
var name = !string.IsNullOrWhiteSpace(player?.DisplayName)
? player!.DisplayName
: !string.IsNullOrWhiteSpace(player?.Username)
? player!.Username
: $"Player{ownerPlayerId}";
var cleaned = new string(name
.Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_')
.ToArray()).Trim('_');
return string.IsNullOrWhiteSpace(cleaned) ? $"Player{ownerPlayerId}_dorm" : $"{cleaned}_dorm";
}
private async Task ResolveInitialDataBlobAsync(long roomId, bool isDorm, long? dormOwnerId)
{
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return string.Empty;
if (isDorm && dormOwnerId is long ownerId)
{
var dormBlob = await rooms.ResolveDormDataBlobNameAsync(ownerId, room.Id);
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
}
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
private async Task ResolveSubRoomUnityAssetIdAsync(long roomId, long subRoomId)
{
return await db.RoomScenes.AsNoTracking()
.Where(s => s.RoomId == roomId && s.OrderIndex == subRoomId)
.Select(s => s.StudioUnityAssetId)
.FirstOrDefaultAsync() ?? string.Empty;
}
///
/// Stable id-per-room-name map for well-known rooms; unknown names get
/// a hash-based id so each room name routes to its own roomId space.
///
private static long MapRoomNameToId(string name) => name.ToLowerInvariant() switch
{
"dormroom" => 1,
"rec_center" or "reccenter" => 2,
"lobby" => 3,
_ => Math.Abs(name.GetHashCode()) % 100_000 + 1000,
};
///
/// Maps a friendly room name to the RoomScene.RoomSceneLocationId GUID
/// baked into resources.assets's AGRoomRuntimeConfig.Rooms[].Scenes[0].
/// This is the foreign key that links a Room's "Home" scene to an
/// AGRoomRuntimeConfig.Locations entry — and is what
/// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0) matches
/// against Location.ReplicationId.
///
/// The asset stores each Room as [Name] [Room.ReplicationId]
/// [Description] [Scene.Name="Home"] [Scene.ReplicationId]
/// [Scene.RoomSceneLocationId] — so the THIRD GUID after the room name
/// is the one we want. Earlier I sent the FIRST (Room.ReplicationId)
/// which is a different namespace entirely; the deserializer logged
/// "unknown scene location ID" because no Location had that GUID.
///
private static string MapRoomNameToReplicationId(string name) => name.ToLowerInvariant() switch
{
"dormroom" => "76d98498-60a1-430c-ab76-b54a29b7a163",
"rec_center" or "reccenter" => "cbad71af-0831-44d8-b8ef-69edafa841f6",
"3dcharades" or "charades" => "4078dfed-24bb-4db7-863f-578ba48d726b",
"discgolflake" or "lake" => "f6f7256c-e438-4299-b99e-d20bef8cf7e0",
"discgolfpropulsion" or "propulsion" => "d9378c9f-80bc-46fb-ad1e-1bed8a674f55",
"dodgeball" => "3d474b26-26f7-45e9-9a36-9b02847d5e6f",
"paddleball" => "d89f74fa-d51e-477a-a425-025a891dd499",
"river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
"homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7",
"quarry" => "ff4c6427-7079-4f59-b22a-69b089420827",
"clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161",
"spillway" => "58763055-2dfb-4814-80b8-16fac5c85709",
"drive-in" or "drivein" => "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3",
"soccer" => "7e01cfe0-820a-406f-b1b3-0a5bf575235c",
"lasertag" => "6d5eea4b-f069-4ed0-9916-0e2f07df0d03",
"cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038",
"recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f",
"recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11",
"recrally" => "56193568-9ae0-498c-8a77-4df79dec91f5",
_ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom
};
}
public class MatchmakingResponseDto
{
[JsonPropertyName("errorCode")]
public int ErrorCode { get; set; }
[JsonPropertyName("roomInstance")]
public RoomInstanceDto? RoomInstance { get; set; } = new();
}
///
/// Mirrors RecNet.RoomInstance — Deserialize at RVA 0x114DAB0. Keys
/// verified by disassembly. JSON casing is camelCase throughout.
///
public class RoomInstanceDto
{
[JsonPropertyName("roomInstanceId")]
public long RoomInstanceId { get; set; }
[JsonPropertyName("roomId")]
public long RoomId { get; set; }
[JsonPropertyName("subRoomId")]
public long SubRoomId { get; set; }
// STRING RoomSceneLocationId GUID — foreign-keys to
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
// = DormRoom's home-scene location ID extracted from resources.assets
// at offset 0x65107ec.
[JsonPropertyName("location")]
public string Location { get; set; } = "76d98498-60a1-430c-ab76-b54a29b7a163";
// STRING enum-name — CloudRegionCode is parsed by name: eu, us, asia,
// jp, none, au, usw, sa, cae, kr, in, ru, rue. Lowercase.
[JsonPropertyName("photonRegionId")]
public string PhotonRegionId { get; set; } = "us";
[JsonPropertyName("photonRoomId")]
public string PhotonRoomId { get; set; } = string.Empty;
[JsonPropertyName("maxCapacity")]
public int MaxCapacity { get; set; } = 8;
[JsonPropertyName("isFull")]
public bool IsFull { get; set; } = false;
[JsonPropertyName("isPrivate")]
public bool IsPrivate { get; set; } = false;
[JsonPropertyName("isInProgress")]
public bool IsInProgress { get; set; } = true;
// Optional keys per disassembly (Util.GetKeyOrDefault).
[JsonPropertyName("dataBlob")]
public string DataBlob { get; set; } = string.Empty;
// 2023 RoomLoading.LoadRoomCommand carries this separately from
// dataBlob. If it is empty, the loader can deserialize room view data
// before the Studio asset bundle has registered its prefabs, producing
// "Bad View Data detected. Expected a prefab, but found none".
[JsonPropertyName("subRoomUnityAssetId")]
public string SubRoomUnityAssetId { get; set; } = string.Empty;
[JsonPropertyName("eventId")]
public long EventId { get; set; } = 0;
// Raw room name (NO leading "^"). Kept clean because server-side code
// derives other values from it — most importantly
// PrivateInstanceService builds the photonRoomId as "^{baseName}_…"
// from this, and MessagesController uses it verbatim as the invite
// message's display name (the "just the event name" case, no "^").
[JsonIgnore]
public string Name { get; set; } = string.Empty;
// Wire "name" field. The 2020.12 watch's PlayerPresence.FriendlyPresence
// (EFAJBHGDIDD.JDICJBJBMMI → BaseAccountModel.get_FriendlyPresence) shows
// roomInstance.name VERBATIM and its built-in default is the literal
// "^DormRoom" — i.e. a player's current room is expected to carry a
// leading "^". So we prefix on serialize and strip on deserialize
// (PlayerPresenceService round-trips this DTO through Redis), keeping the
// C# Name raw. Empty stays empty so the watch falls back to its own
// "^DormRoom" default. Invite MESSAGES keep the plain name — that's a
// separate field built from the raw Name in MessagesController.
[JsonPropertyName("name")]
public string NameWire
{
get => string.IsNullOrEmpty(Name) || Name.StartsWith('^') ? Name : "^" + Name;
set => Name = !string.IsNullOrEmpty(value) && value.StartsWith('^') ? value[1..] : value;
}
// 2020.12 added these keys to RoomInstance.Deserialize. Verified via
// Cpp2IL ISIL on CIIBGMBOFEI.PPGFHEDFBEA (the obfuscated RoomInstance
// class). Missing → "Malformed Response" in matchmaking.
[JsonPropertyName("roomInstanceType")]
public int RoomInstanceType { get; set; } = 0;
[JsonPropertyName("clubId")]
public long ClubId { get; set; } = 0;
[JsonPropertyName("roomCode")]
public string RoomCode { get; set; } = string.Empty;
// NOTE: PascalCase 'E'. The 2020.12 ISIL literally loads "EncryptVoiceChat"
// (not "encryptVoiceChat"). LitJson is case-sensitive.
[JsonPropertyName("EncryptVoiceChat")]
public bool EncryptVoiceChat { get; set; } = false;
///
/// Returns a copy of with photonRoomId
/// and dataBlob blanked out — safe to send to OTHER players
/// (friend-list presence, push updates). The watch only needs
/// roomId / name / isPrivate to render the
/// friend-list row; it joins via /goto/player/{id} or
/// /goto/instance/{id}, both of which enforce
/// .
///
/// Without this redaction, anyone could read another player's
/// presence and use the leaked photonRoomId to JoinByName
/// in Photon directly, bypassing our invite-list check.
///
public static RoomInstanceDto? Redact(RoomInstanceDto? room)
{
if (room is null) return null;
return new RoomInstanceDto
{
RoomInstanceId = room.RoomInstanceId,
RoomId = room.RoomId,
SubRoomId = room.SubRoomId,
Location = room.Location,
PhotonRegionId = room.PhotonRegionId,
PhotonRoomId = string.Empty,
MaxCapacity = room.MaxCapacity,
IsFull = room.IsFull,
IsPrivate = room.IsPrivate,
IsInProgress = room.IsInProgress,
DataBlob = string.Empty,
SubRoomUnityAssetId = room.SubRoomUnityAssetId,
EventId = room.EventId,
Name = room.Name,
};
}
}
/// Rate limit for "wants to join" messages: one per (requester,
/// owner, room instance) per . Later retries and other
/// instances still go through. Per replica, which is enough to stop a
/// retry loop from flooding an inbox.
internal static class JoinRequestCooldown
{
public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
/// True when no request for this key was sent within the window
/// (and records this one); false while it is cooling down.
public static bool TryStart(long requester, long owner, long roomInstanceId)
{
var now = DateTime.UtcNow;
if (Last.Count > 10_000)
foreach (var (key, at) in Last)
if (now - at > Window) Last.TryRemove(key, out _);
var k = (requester, owner, roomInstanceId);
while (true)
{
if (!Last.TryGetValue(k, out var prev))
{
if (Last.TryAdd(k, now)) return true;
continue;
}
if (now - prev < Window) return false;
if (Last.TryUpdate(k, now, prev)) return true;
}
}
}