mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
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.
This commit is contained in:
@@ -0,0 +1,1095 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Match;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class GoToController(
|
||||
IConfiguration config,
|
||||
PlayerPresenceService presence,
|
||||
PrivateInstanceService privateInstances,
|
||||
RoomService rooms,
|
||||
DorkNet.Server.Data.DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
ILogger<GoToController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("/goto/room/{roomName}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> GoToRoom(
|
||||
string roomName,
|
||||
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false)
|
||||
{
|
||||
// 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);
|
||||
if (createPrivateInstance) await ApplyPrivateInstanceAsync(resp, subRoomId: 0);
|
||||
await RecordResponseAsync(resp);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
[HttpPost("/goto/room")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> GoToRoomFromBody(
|
||||
[FromForm(Name = "RoomName")] string? roomNameForm,
|
||||
[FromForm(Name = "roomName")] string? roomNameLower,
|
||||
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost("/goto/none")]
|
||||
public IActionResult GoToNone() => Ok();
|
||||
|
||||
/// <summary>POST <c>/goto/room/{room}/{subRoom}</c> — sub-room form
|
||||
/// from <c>RecNet.Matchmaking.GoToSubRoom</c>
|
||||
/// (<c>Cpp2IL_ISIL/.../Matchmaking.txt:4682-4702</c>):
|
||||
/// <c>String.Concat("goto/room/", urlEncode(room), "/", urlEncode(subRoom))</c>.
|
||||
/// Used by baked AG-Original rooms with multiple scenes (e.g.
|
||||
/// <c>StuntRunner/TheMainEvent</c>, the playable gauntlet inside
|
||||
/// the StuntRunner lobby).
|
||||
///
|
||||
/// We resolve to the parent room's <see cref="RoomEntity"/> for
|
||||
/// the canonical RoomId + LocationReplicationId, then expose the
|
||||
/// sub-room name via the <c>name</c> field so the client can
|
||||
/// render the right scene. If we ever build a per-sub-room scene
|
||||
/// location lookup table, switch <see cref="RoomInstanceDto.Location"/>
|
||||
/// to the matching scene's ReplicationId.</summary>
|
||||
[HttpPost("/goto/room/{roomName}/{subRoomName}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> GoToSubRoom(
|
||||
string roomName, string subRoomName,
|
||||
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false)
|
||||
{
|
||||
// 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).
|
||||
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;
|
||||
// dataBlob on the matchmaking response tells the client which
|
||||
// PersistedRoomData blob to download for the entered scene.
|
||||
// RoomInstance.Deserialize at RVA 0x114DAB0 reads it via the
|
||||
// string-with-default reader (call 0x180EE6210), so empty is
|
||||
// technically legal — but if it's empty the watch falls back
|
||||
// to the parent room's RoomDataBlobName, which is the lobby's
|
||||
// blob, so the sub-scene visually never changes.
|
||||
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
|
||||
|
||||
// Private 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.
|
||||
if (createPrivateInstance) await ApplyPrivateInstanceAsync(resp, subRoomId: subId);
|
||||
|
||||
await RecordResponseAsync(resp);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
/// <summary>Per-sub-room scene location overrides for baked
|
||||
/// AG-Original rooms with multiple scenes. Each entry maps a
|
||||
/// <c>"{room}/{subRoom}"</c> key (lowercase) to the
|
||||
/// <c>RoomSceneLocationId</c> 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 <c>AGRoomSettings.TryGetRoomSceneLocationById</c>
|
||||
/// rejects unknown GUIDs with "RecNet game session contains
|
||||
/// unknown scene location ID" and refuses to load.</summary>
|
||||
/// <summary>Sub-room scene GUIDs extracted from the bundled
|
||||
/// <c>resources.assets</c> AGRoomRuntimeConfig.Rooms[].Scenes[]
|
||||
/// using <c>tools/extract-rooms-binary.py</c>. Each entry maps
|
||||
/// <c>"{room}/{subRoom}"</c> (lowercase) to the target
|
||||
/// <c>RoomSceneLocationId</c> GUID. Missing sub-rooms reuse the
|
||||
/// parent room's lobby location (door opens but transition no-ops).</summary>
|
||||
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}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> GoToEvent(long eventId)
|
||||
{
|
||||
var resp = await BuildResponseAsync("DormRoom");
|
||||
await RecordResponseAsync(resp);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
[HttpPost("/goto/club/{clubId:long}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> 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(),
|
||||
});
|
||||
}
|
||||
|
||||
var resp = await BuildResponseAsync("RecCenter");
|
||||
resp.RoomInstance!.ClubId = club.Id;
|
||||
await RecordResponseAsync(resp);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
[HttpPost("/goto/code")]
|
||||
[HttpPost("/goto/code/{code}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> GoToCode(string? code = null)
|
||||
{
|
||||
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<ActionResult<MatchmakingResponseDto>> 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);
|
||||
await RecordResponseAsync(resp);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/goto/instance/{roomInstanceId}</c> — 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 <see cref="PrivateInstanceService.PrivateInstance"/>
|
||||
/// 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).</summary>
|
||||
[HttpPost("/goto/instance/{roomInstanceId:long}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> GoToInstance(long roomInstanceId)
|
||||
{
|
||||
var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId);
|
||||
await RecordResponseAsync(resp);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
[HttpPost("/goto/invite/{inviteId:long}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> 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));
|
||||
|
||||
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
|
||||
// DELETE usually wins the race, so the message row is gone
|
||||
// by the time we run FirstOrDefaultAsync. The
|
||||
// (instance, player) → LatestInviteMessageId mapping that
|
||||
// SendInvite writes onto the PrivateInstanceInviteeEntity
|
||||
// survives the message deletion 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);
|
||||
}
|
||||
}
|
||||
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<MatchmakingResponseDto> 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/goto/player/{playerId}</c> — join the room
|
||||
/// the target player is currently in.
|
||||
///
|
||||
/// For PUBLIC rooms: mirror the target's room name and rebuild a
|
||||
/// fresh RoomInstance (the caller may end up in a different
|
||||
/// matchmade instance, which is fine for public rooms).
|
||||
///
|
||||
/// 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 <see cref="PrivateInstanceService.CanJoin"/>;
|
||||
/// otherwise we refuse with errorCode=9 (RoomNotJoinable).</summary>
|
||||
[HttpPost("/goto/player/{playerId:long}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> 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);
|
||||
// Notify the OWNER that someone tried to follow them in.
|
||||
// Helps surface the rejection so the owner knows there's
|
||||
// an interested party — they can choose to send an invite.
|
||||
await notifications.PrivateInstanceJoinRejected(
|
||||
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0);
|
||||
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 = theirRoom };
|
||||
}
|
||||
else
|
||||
{
|
||||
var roomName = await db.Rooms
|
||||
.Where(r => r.Id == theirRoom.RoomId)
|
||||
.Select(r => r.Name)
|
||||
.FirstOrDefaultAsync();
|
||||
resp = await BuildResponseAsync(roomName ?? "DormRoom");
|
||||
}
|
||||
await RecordResponseAsync(resp);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private async Task RecordResponseAsync(MatchmakingResponseDto response)
|
||||
{
|
||||
if (this.CurrentPlayerId() is not long playerId) return;
|
||||
|
||||
presence.SetRoom(playerId, response.RoomInstance);
|
||||
|
||||
// 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 (response.RoomInstance.RoomId != 1)
|
||||
await RecordVisitAsync(playerId, response.RoomInstance.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, response.RoomInstance);
|
||||
|
||||
// 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).
|
||||
}
|
||||
|
||||
/// <summary>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 <see cref="PrivateInstanceService"/>
|
||||
/// so invitees coming in via /goto/instance/{id} can find this
|
||||
/// exact Photon match.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Upsert the (room, player) <see cref="RoomVisitEntity"/>
|
||||
/// row for this join. New rows bump
|
||||
/// <see cref="RoomEntity.VisitorCount"/> (unique-visitor metric);
|
||||
/// every join — new or repeat — bumps
|
||||
/// <see cref="RoomEntity.VisitCount"/> + the per-player counter.</summary>
|
||||
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)
|
||||
{
|
||||
var friendIds = await db.Relationships
|
||||
.Where(r => r.Status == DorkNet.Server.Data.Entities.RelationshipStatus.Friend &&
|
||||
(r.RequesterId == playerId || r.TargetId == playerId))
|
||||
.Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId)
|
||||
.ToListAsync();
|
||||
logger.LogInformation(
|
||||
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)",
|
||||
playerId, room.RoomId, room.Name, friendIds.Count);
|
||||
if (friendIds.Count == 0) return;
|
||||
await notifications.PlayerEnteredRoom(friendIds, playerId, room);
|
||||
}
|
||||
|
||||
private async Task<MatchmakingResponseDto> 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;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 photonRoom = (isDorm && dormOwnerId is long ownerForName)
|
||||
? $"^dormroom_p{ownerForName}"
|
||||
: $"^{roomName.ToLowerInvariant()}_{roomId}";
|
||||
var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId);
|
||||
|
||||
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 = roomName,
|
||||
MaxCapacity = 8,
|
||||
IsFull = false,
|
||||
IsPrivate = false,
|
||||
IsInProgress = true,
|
||||
DataBlob = dataBlob,
|
||||
},
|
||||
};
|
||||
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: resp.RoomInstance.Name,
|
||||
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<string> 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 db.DormStates.AsNoTracking()
|
||||
.Where(d => d.PlayerId == ownerId)
|
||||
.Select(d => d.CurrentDataBlobName)
|
||||
.FirstOrDefaultAsync();
|
||||
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||||
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
|
||||
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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",
|
||||
_ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom
|
||||
};
|
||||
}
|
||||
|
||||
public class MatchmakingResponseDto
|
||||
{
|
||||
[JsonPropertyName("errorCode")]
|
||||
public int ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("roomInstance")]
|
||||
public RoomInstanceDto? RoomInstance { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors RecNet.RoomInstance — Deserialize at RVA 0x114DAB0. Keys
|
||||
/// verified by disassembly. JSON casing is camelCase throughout.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
[JsonPropertyName("eventId")]
|
||||
public long EventId { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="room"/> with <c>photonRoomId</c>
|
||||
/// and <c>dataBlob</c> blanked out — safe to send to OTHER players
|
||||
/// (friend-list presence, push updates). The watch only needs
|
||||
/// <c>roomId</c> / <c>name</c> / <c>isPrivate</c> to render the
|
||||
/// friend-list row; it joins via <c>/goto/player/{id}</c> or
|
||||
/// <c>/goto/instance/{id}</c>, both of which enforce
|
||||
/// <see cref="PrivateInstanceService.CanJoinAsync"/>.
|
||||
///
|
||||
/// Without this redaction, anyone could read another player's
|
||||
/// presence and use the leaked <c>photonRoomId</c> to JoinByName
|
||||
/// in Photon directly, bypassing our invite-list check.
|
||||
/// </summary>
|
||||
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,
|
||||
EventId = room.EventId,
|
||||
Name = room.Name,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.GameSessions;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Match;
|
||||
|
||||
/// <summary>
|
||||
/// match.rec.net — dedicated matchmaking service used by later 2020 builds.
|
||||
/// Delegates to the Postgres-backed GameSessionService.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class MatchController(
|
||||
GameSessionService sessionService,
|
||||
PlayerPresenceService presence,
|
||||
PrivateInstanceService privateInstances,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchController> logger) : ControllerBase
|
||||
{
|
||||
/// <summary>POST <c>match.*/roominstance/{id}/reportjoinresult</c> —
|
||||
/// the watch fires this AFTER its Photon connect+join attempt,
|
||||
/// reporting how it went via <c>RecNet.Matchmaking+GameJoinResult</c>
|
||||
/// (Matchmaking.cs:121):
|
||||
/// 0 = Success, 1 = FailedToConnectToRegion,
|
||||
/// 2 = FailedToConnectToRoom, 3 = FailedToSpawnPlayer.
|
||||
/// Body is form-urlencoded <c>result=N</c>. Watch ignores the
|
||||
/// response body and just needs 2xx (Matchmaking.txt:6668-6699), so
|
||||
/// we mostly use this for visibility into Photon-side issues —
|
||||
/// without it, all we see in the catch-all is an opaque "200, empty
|
||||
/// reply" and no clue why a player crashed back to dorm.</summary>
|
||||
[HttpPost("/roominstance/{instanceId:long}/reportjoinresult")]
|
||||
public IActionResult ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result)
|
||||
{
|
||||
var code = result ?? -1;
|
||||
var label = code switch
|
||||
{
|
||||
0 => "Success",
|
||||
1 => "FailedToConnectToRegion",
|
||||
2 => "FailedToConnectToRoom",
|
||||
3 => "FailedToSpawnPlayer",
|
||||
_ => $"Unknown({code})",
|
||||
};
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
if (code == 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[joinresult] player={Player} instance={Instance} OK ({Label})",
|
||||
pid, instanceId, label);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label}) — Photon-side error, check Photon AppId/region/auth",
|
||||
pid, instanceId, code, label);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>PUT <c>/roominstance/{id}/inprogress</c> — fired by
|
||||
/// <c>RecNet.Matchmaking.SetGameInProgress(bool inProgress)</c>
|
||||
/// (Matchmaking.cs:333). Body is form-urlencoded
|
||||
/// <c>inProgress=true|false</c>. The watch uses this to mark a
|
||||
/// match as "in progress" so newcomers are sent to a fresh instance
|
||||
/// instead of joining a game already underway (e.g. paintball
|
||||
/// during a round). Our matchmaker doesn't gate on this yet — log
|
||||
/// + 200 to take it off the catch-all.</summary>
|
||||
[HttpPut("/roominstance/{instanceId:long}/inprogress")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public IActionResult SetInProgress(
|
||||
long instanceId,
|
||||
[FromForm(Name = "inProgress")] bool? inProgress)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
logger.LogInformation(
|
||||
"[set-inprogress] player={Player} instance={Instance} inProgress={Flag}",
|
||||
pid, instanceId, inProgress);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/roominstance/{id}/markprivate</c> — promote
|
||||
/// the caller's current public instance to an invite-gated
|
||||
/// PrivateInstance row. The watch only needs a RecNet result
|
||||
/// wrapper, but the server persists the privacy state so later
|
||||
/// invite joins are enforced by <see cref="PrivateInstanceService"/>.</summary>
|
||||
[HttpPost("/roominstance/{instanceId:long}/markprivate")]
|
||||
public async Task<IActionResult> MarkPrivate(long instanceId)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var existing = await db.PrivateInstances.FirstOrDefaultAsync(p => p.Id == instanceId);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.OwnerPlayerId != pid) return Forbid();
|
||||
return Ok(new { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
var current = presence.GetRoom(pid);
|
||||
if (current is null || current.RoomInstanceId != instanceId)
|
||||
return NotFound(new { Success = false, Error = "room instance not found for current player" });
|
||||
|
||||
db.PrivateInstances.Add(new PrivateInstanceEntity
|
||||
{
|
||||
Id = instanceId,
|
||||
RoomId = current.RoomId,
|
||||
SubRoomId = current.SubRoomId,
|
||||
OwnerPlayerId = pid,
|
||||
PhotonRoomId = current.PhotonRoomId,
|
||||
Location = current.Location,
|
||||
DataBlob = current.DataBlob,
|
||||
PhotonRegion = current.PhotonRegionId,
|
||||
MaxCapacity = current.MaxCapacity,
|
||||
Name = current.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("[mark-private] player={Player} instance={Instance}", pid, instanceId);
|
||||
return Ok(new { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>GET/POST <c>/roominstance/{id}/roomcode</c> — raw
|
||||
/// JSON string room code shown by the watch. Codes are stable for
|
||||
/// an instance id and do not expose the numeric id directly.</summary>
|
||||
[HttpGet("/roominstance/{instanceId:long}/roomcode")]
|
||||
[HttpPost("/roominstance/{instanceId:long}/roomcode")]
|
||||
public IActionResult RoomCode(long instanceId)
|
||||
{
|
||||
var code = RoomCodeService.Generate(instanceId);
|
||||
return Content(JsonSerializer.Serialize(code), "application/json");
|
||||
}
|
||||
|
||||
[HttpPost("/v1/find")]
|
||||
public async Task<ActionResult<JoinGameSessionResponse>> Find([FromBody] JoinRandomGameSessionRequest req)
|
||||
{
|
||||
var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId);
|
||||
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Success, GameSession = session });
|
||||
}
|
||||
|
||||
[HttpPost("/v1/join/{sessionId:long}")]
|
||||
public async Task<ActionResult<JoinGameSessionResponse>> Join(long sessionId)
|
||||
{
|
||||
var session = await sessionService.GetByIdAsync(sessionId);
|
||||
if (session is null) return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound });
|
||||
if (session.IsFull) return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full });
|
||||
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Success, GameSession = session });
|
||||
}
|
||||
|
||||
[HttpPost("/v1/leave/{sessionId:long}")]
|
||||
public async Task<IActionResult> Leave(long sessionId)
|
||||
{
|
||||
await sessionService.PlayerLeftAsync(sessionId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("/v1/session/{sessionId:long}")]
|
||||
public async Task<ActionResult<GameSession>> GetSession(long sessionId)
|
||||
{
|
||||
var session = await sessionService.GetByIdAsync(sessionId);
|
||||
if (session is null) return NotFound();
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>match.*/room/{id}/instances</c> — the
|
||||
/// "RoomInstanceBrowser" the watch shows when you tap a room tile.
|
||||
/// Returns the currently-active instances so the player can see
|
||||
/// which one their friends are in (and join it) instead of being
|
||||
/// matchmade into a fresh empty room. Verified URL at
|
||||
/// Matchmaking.txt:6286; response is a
|
||||
/// <c>List<SimpleRoomInstance></c> per
|
||||
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
|
||||
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
|
||||
/// HasModPresent, HashedInstanceId.
|
||||
///
|
||||
/// We derive instances from PlayerPresenceService's per-player
|
||||
/// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId
|
||||
/// is one instance, with the players currently presenced there.
|
||||
/// PrivateInstances are surfaced as well so the inviter and
|
||||
/// invitee see the same room in the browser.</summary>
|
||||
[HttpGet("/room/{roomId:long}/instances")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> RoomInstances(long roomId)
|
||||
{
|
||||
var presenced = presence.EnumerateActiveInstances(roomId).ToList();
|
||||
// Only surface the caller's OWN private instances — we never
|
||||
// leak someone else's PhotonRoomId into the browser. The Photon
|
||||
// join check still requires CanJoinAsync, but redacting at the
|
||||
// list level matches the existing RoomInstanceDto.Redact policy.
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
var privates = pid == 0
|
||||
? new List<Data.Entities.PrivateInstanceEntity>()
|
||||
: await db.PrivateInstances
|
||||
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
|
||||
.ToListAsync();
|
||||
|
||||
// Merge: presence rows are authoritative (player currently
|
||||
// there); add any owned private instance the inviter created
|
||||
// but nobody's joined yet so the inviter can still see it.
|
||||
var byKey = new Dictionary<string, SimpleInstance>(StringComparer.Ordinal);
|
||||
foreach (var (room, playerIds) in presenced)
|
||||
{
|
||||
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
|
||||
byKey[room.PhotonRoomId] = new SimpleInstance(
|
||||
RoomInstanceId: room.RoomInstanceId,
|
||||
RoomId: room.RoomId,
|
||||
SubRoomId: room.SubRoomId,
|
||||
IsFull: false,
|
||||
CreatedAt: DateTime.UtcNow,
|
||||
PlayerIds: playerIds.Select(p => (int)p).ToList(),
|
||||
SubroomName: room.Name ?? string.Empty,
|
||||
PlayerCount: playerIds.Count,
|
||||
HasModPresent: false,
|
||||
HashedInstanceId: room.PhotonRoomId);
|
||||
}
|
||||
foreach (var p in privates)
|
||||
{
|
||||
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
|
||||
byKey[p.PhotonRoomId] = new SimpleInstance(
|
||||
RoomInstanceId: p.Id,
|
||||
RoomId: p.RoomId,
|
||||
SubRoomId: p.SubRoomId,
|
||||
IsFull: false,
|
||||
CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
|
||||
PlayerIds: new List<int>(),
|
||||
SubroomName: p.Name,
|
||||
PlayerCount: 0,
|
||||
HasModPresent: false,
|
||||
HashedInstanceId: p.PhotonRoomId);
|
||||
}
|
||||
|
||||
// Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE
|
||||
// keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt
|
||||
// lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull,
|
||||
// createdAt, playerIds}). SubroomName / PlayerCount /
|
||||
// HasModPresent / HashedInstanceId are populated locally on the
|
||||
// watch (PlayerIds.Count, local hash, etc.) — not read from
|
||||
// JSON, so we don't send them. Earlier PascalCase response made
|
||||
// every field default to 0/null on the watch, breaking the
|
||||
// browser.
|
||||
return Ok(byKey.Values.Select(i => new
|
||||
{
|
||||
roomInstanceId = i.RoomInstanceId,
|
||||
roomId = i.RoomId,
|
||||
subRoomId = i.SubRoomId,
|
||||
isFull = i.IsFull,
|
||||
createdAt = i.CreatedAt,
|
||||
playerIds = i.PlayerIds.ToArray(),
|
||||
}));
|
||||
}
|
||||
|
||||
private sealed record SimpleInstance(
|
||||
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
|
||||
DateTime CreatedAt, List<int> PlayerIds, string SubroomName,
|
||||
int PlayerCount, bool HasModPresent, string HashedInstanceId);
|
||||
|
||||
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
|
||||
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
|
||||
/// enum 4 = DELETE). The id is a MessageEntity row id (V2 game and
|
||||
/// party invites are stored as Type 6/7 messages). Mark it read so the inviter's
|
||||
/// inbox stops nagging, and remove the invitee from the private
|
||||
/// instance's allow-list so they can't change their mind without
|
||||
/// a fresh invite.</summary>
|
||||
[HttpDelete("/invite/{messageId:long}")]
|
||||
public async Task<IActionResult> DeclineInvite(long messageId)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var msg = await db.Messages
|
||||
.FirstOrDefaultAsync(m => m.Id == messageId && m.RecipientPlayerId == pid);
|
||||
if (msg is null) return NotFound();
|
||||
|
||||
if (msg.ReadAt is null)
|
||||
{
|
||||
msg.ReadAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Current invite messages carry the bare roomInstanceId in
|
||||
// Data/Body. Older local DBs may still have the legacy
|
||||
// "Invite to private match (instance N)" text, so accept both.
|
||||
if (TryParseRoomInstanceId(msg.Body, out var instId))
|
||||
{
|
||||
await privateInstances.RevokeInviteAsync(instId, pid);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("[invite] declined messageId={Id} by player={Player}", messageId, pid);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// Same three-shape parser as GoToController.TryParseRoomInstanceId
|
||||
// (see that file for the rationale): JSON for current Type-6/7
|
||||
// invites, bare int for the pre-fix shape, "Invite to private match
|
||||
// (instance N)" text for ancient rows.
|
||||
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))
|
||||
{
|
||||
// See GoToController.TryParseRoomInstanceId for the
|
||||
// rationale on accepting both string and number forms.
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Match;
|
||||
|
||||
/// <summary>
|
||||
/// match.rec.net/player/* — endpoints that operate on the local player's
|
||||
/// matchmaking session. These were previously falling through to the
|
||||
/// GlobalCatchAllController which returned the kitchen-sink "RecNetResult
|
||||
/// shotgun" body. That body lacks the required keys for the actual response
|
||||
/// types and the client crashes with KeyNotFoundException.
|
||||
///
|
||||
/// The endpoints here use the same camelCase PlayerPresence shape as
|
||||
/// PlayerPresenceController (verified at RVA 0x1458920).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class MatchPlayerController(
|
||||
PlayerPresenceService presence,
|
||||
NotificationService notifications,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchPlayerController> log) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// POST <c>/player/login</c> — the 2020 watch starts every session by
|
||||
/// posting here with a fresh <c>LoginLock</c> GUID it generated client-side.
|
||||
/// We persist the lock as the canonical "active session" marker for this
|
||||
/// player so subsequent heartbeats can be validated against it; a NEW
|
||||
/// login overwrites the lock and any older session's heartbeats start
|
||||
/// failing, which is the intended kick-the-first-session behaviour.
|
||||
/// </summary>
|
||||
[HttpPost("/player/login")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
|
||||
{
|
||||
var replaced = presence.SetLock(playerId, loginLock);
|
||||
if (replaced)
|
||||
{
|
||||
log.LogWarning(
|
||||
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push",
|
||||
playerId);
|
||||
// The new session hasn't opened its SignalR socket yet
|
||||
// (negotiate fires AFTER /player/login on the watch's
|
||||
// boot sequence — verified in the live trace), so the
|
||||
// ModerationKick push hits only the OLDER session's
|
||||
// existing connections. They'll show the "you've been
|
||||
// kicked" dialog and gracefully disconnect via the
|
||||
// watch's LocalModerationKickSelf path.
|
||||
await notifications.KickStaleSession(playerId,
|
||||
"Your account was signed in on another device.");
|
||||
}
|
||||
}
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /player/logout — fires when the client gives up on a session
|
||||
/// (e.g. Photon connection couldn't be established, client aborts).
|
||||
/// Clear the cached presence so the next /goto starts fresh.
|
||||
/// </summary>
|
||||
[HttpPost("/player/logout")]
|
||||
public IActionResult PlayerLogout()
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (playerId != 0) presence.Clear(playerId);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST <c>/player/notifydisconnect</c> — fired by
|
||||
/// <c>RecNet.Matchmaking.NotifyPlayerDisconnected(int otherPlayerId)</c>
|
||||
/// (Matchmaking.cs:326). Body is form-urlencoded
|
||||
/// <c>otherPlayerId=<id></c>. The watch uses this to tell the
|
||||
/// server "I just saw this other Photon participant disappear" —
|
||||
/// e.g. when someone leaves a room via the menu. Real Rec Room
|
||||
/// likely used it to GC sticky presence rows; we just acknowledge
|
||||
/// and log so it stops falling through to GlobalCatchAll.
|
||||
///
|
||||
/// Returns 200 with no body (BestHTTP ignores the response per
|
||||
/// Matchmaking.txt:6668-6699).
|
||||
/// </summary>
|
||||
[HttpPost("/player/notifydisconnect")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public IActionResult NotifyDisconnect([FromForm(Name = "otherPlayerId")] int? otherPlayerId)
|
||||
{
|
||||
var caller = TryGetCurrentPlayerId();
|
||||
log.LogInformation(
|
||||
"[player-notify-disconnect] caller={Caller} reported other={Other} left",
|
||||
caller, otherPlayerId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /player/heartbeat — every ~5–30s while connected. Client expects
|
||||
/// a PlayerPresence response that mirrors its own cached state.
|
||||
/// CRITICAL: must include the player's CURRENT roomInstance, not null.
|
||||
/// Returning null while the client knows it's in DormRoom triggers
|
||||
/// "presence heartbeat response indicates local presence is out-of-sync"
|
||||
/// repeatedly → eventual "Error: -2" disconnect dialog.
|
||||
///
|
||||
/// We pull the current room from PlayerPresenceService, which records
|
||||
/// each successful /goto/room/* call.
|
||||
/// </summary>
|
||||
[HttpPost("/player/heartbeat")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<PresenceDto>> Heartbeat([FromForm(Name = "LoginLock")] string? loginLock)
|
||||
{
|
||||
// We track the LoginLock per player in /player/login (still useful
|
||||
// for admin diagnostics + future session-management) but
|
||||
// intentionally DO NOT 401 here on stale locks. Returning 401 on
|
||||
// a heartbeat causes the watch to yank the player out of the
|
||||
// current Photon room mid-session — symptoms include
|
||||
// "Cannot send messages when not connected" + "Head/LeftHand/
|
||||
// RightHand being destroyed" + "presence heartbeat response
|
||||
// indicates local presence is out-of-sync" — which is worse than
|
||||
// letting two sessions co-exist briefly. If we want true
|
||||
// single-session enforcement we'll do it via SignalR push to the
|
||||
// older session and a graceful client-side disconnect, not via
|
||||
// an HTTP 401 mid-heartbeat.
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
var room = await GetPresenceRoomForResponseAsync(playerId);
|
||||
return Ok(new PresenceDto
|
||||
{
|
||||
PlayerId = playerId,
|
||||
StatusVisibility = 1,
|
||||
DeviceClass = 0,
|
||||
VrMovementMode = 0,
|
||||
RoomInstance = room,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/player/statusvisibility")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
var room = await GetPresenceRoomForResponseAsync(playerId);
|
||||
return Ok(new PresenceDto
|
||||
{
|
||||
PlayerId = playerId,
|
||||
StatusVisibility = value ?? 1,
|
||||
DeviceClass = 0,
|
||||
VrMovementMode = 0,
|
||||
RoomInstance = room,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/player/vrmovementmode")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<PresenceDto>> SetVrMovementMode([FromForm(Name = "vrMovementMode")] int? value)
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
var room = await GetPresenceRoomForResponseAsync(playerId);
|
||||
return Ok(new PresenceDto
|
||||
{
|
||||
PlayerId = playerId,
|
||||
StatusVisibility = 1,
|
||||
DeviceClass = 0,
|
||||
VrMovementMode = value ?? 0,
|
||||
RoomInstance = room,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/player/avoidjuniors</c> — returns a primitive boolean.
|
||||
/// Verified against
|
||||
/// Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:7728 (URL) and 7737
|
||||
/// (<c>PromiseExtensions.ExpectPrimitiveResponse</c>) — the watch
|
||||
/// expects a bare JSON <c>true</c>/<c>false</c>, not an array or
|
||||
/// object. Returning <c>[]</c> from the catch-all triggered
|
||||
/// "Failed to get avoid juniors status: Malformed Response" + a
|
||||
/// Convert.ChangeType InvalidCastException, which left the
|
||||
/// matchmaking init in a never-completes state on some boot flows.
|
||||
/// </summary>
|
||||
[HttpGet("/player/avoidjuniors")]
|
||||
public ActionResult<bool> GetAvoidJuniors() => Ok(false);
|
||||
|
||||
/// <summary>PUT <c>/player/avoidjuniors</c> — body
|
||||
/// <c>avoidJuniors=true|false</c> (Matchmaking.txt:7883). Stub for
|
||||
/// now; the multi-tenant server doesn't currently track this
|
||||
/// preference per-player. Returns the new value as a JSON
|
||||
/// primitive so the watch's ExpectPrimitiveResponse is happy.</summary>
|
||||
[HttpPut("/player/avoidjuniors")]
|
||||
[HttpPost("/player/avoidjuniors")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public ActionResult<bool> SetAvoidJuniors([FromForm(Name = "avoidJuniors")] bool? value)
|
||||
=> Ok(value ?? false);
|
||||
|
||||
/// <summary>POST <c>/player/photonregionpings</c> — watch reports
|
||||
/// its measured Photon region ping vector so the server can pick
|
||||
/// the best region for this player's matchmaking. Body is form-
|
||||
/// urlencoded: <c>region=eu&ping=42&region=us&ping=80&...</c>
|
||||
/// (one (region, ping) pair per row, repeated). Persists the
|
||||
/// freshest measurement per (player, region) pair in
|
||||
/// <see cref="PlayerPresenceService"/>'s in-memory map — the
|
||||
/// matchmaking GoTo flow reads this to bias instance creation
|
||||
/// toward the player's lowest-ping region.
|
||||
/// Verified URL at Matchmaking.txt:7280.</summary>
|
||||
// 2020.12 calls this with the BestHTTP method enum value 3 (= PUT, not POST).
|
||||
// Server-side route was POST → 405 Method Not Allowed → "Failed to upload
|
||||
// photon region pings: HTTP Error 405". Accept GET/POST/PUT to cover both
|
||||
// 2020.03 (POST) and 2020.12 (PUT) without guessing wrong on the enum.
|
||||
[HttpPost("/player/photonregionpings")]
|
||||
[HttpPut("/player/photonregionpings")]
|
||||
[HttpGet("/player/photonregionpings")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public IActionResult PhotonRegionPings()
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (playerId == 0) return Ok();
|
||||
|
||||
var form = Request.Form;
|
||||
var regions = form["region"].ToArray();
|
||||
var pings = form["ping"].ToArray();
|
||||
var pairs = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < Math.Min(regions.Length, pings.Length); i++)
|
||||
{
|
||||
var r = regions[i]?.Trim();
|
||||
if (string.IsNullOrEmpty(r)) continue;
|
||||
if (!int.TryParse(pings[i], out var p)) continue;
|
||||
// Sanity bound — discard wild values from misbehaving clients.
|
||||
pairs[r] = Math.Clamp(p, 0, 2000);
|
||||
}
|
||||
if (pairs.Count > 0)
|
||||
presence.SetPhotonRegionPings(playerId, pairs);
|
||||
log.LogInformation("[player-region-pings] player={Player} regions={Count}", playerId, pairs.Count);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// Heartbeat / logout are unauthenticated endpoints (the client hits them
|
||||
// before any token is acquired in some boot flows), so we use the
|
||||
// optional `CurrentPlayerId()` extension and treat 0 as "anonymous".
|
||||
// Cast to int because the wire shape's PlayerId field is an int — fits
|
||||
// every accountId we mint (random in [100_000, 9_999_999)).
|
||||
private int TryGetCurrentPlayerId() =>
|
||||
this.CurrentPlayerId() is long id ? (int)id : 0;
|
||||
|
||||
private async Task<RoomInstanceDto?> GetPresenceRoomForResponseAsync(int playerId)
|
||||
{
|
||||
if (playerId == 0) return null;
|
||||
|
||||
var room = presence.GetRoom(playerId);
|
||||
if (room is null)
|
||||
{
|
||||
// Boot-time race: the watch hits /player/heartbeat before
|
||||
// /goto/room/DormRoom has finished recording presence. The
|
||||
// watch's OnPresenceHeartbeatResponse compares
|
||||
// (cachedRoomInstance != null) XOR (serverRoomInstance != null)
|
||||
// (Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:8584) — if its local
|
||||
// cache already has a dorm but we hand back null, it fires
|
||||
// "RecNet presence heartbeat response indicates local presence
|
||||
// is out-of-sync!" and tears down the local player (the
|
||||
// RigidbodyEx Head/Hand "Cannot set the parent" warnings at
|
||||
// [line 585] are the destruction shrapnel). Synthesise a
|
||||
// default dorm RoomInstance instead so the heartbeat never
|
||||
// returns null for an authenticated player. Matches what
|
||||
// /goto/room/DormRoom builds — same RoomId, same
|
||||
// PhotonRoomId pattern, same location GUID — so the watch's
|
||||
// RoomInstancesAreDifferent finds them identical.
|
||||
var fallback = await BuildDefaultDormPresenceAsync(playerId);
|
||||
if (fallback is not null)
|
||||
{
|
||||
log.LogInformation(
|
||||
"[player-heartbeat] synthesised dorm fallback player={PlayerId} room={RoomId} dataBlob={DataBlob} (presence cache miss)",
|
||||
playerId, fallback.RoomId, fallback.DataBlob);
|
||||
presence.SetRoom(playerId, fallback);
|
||||
return fallback;
|
||||
}
|
||||
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(room.DataBlob))
|
||||
{
|
||||
var resolved = await ResolveDataBlobForPresenceAsync(room.RoomId, playerId);
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
log.LogWarning(
|
||||
"[player-heartbeat] repairing empty presence dataBlob player={PlayerId} room={RoomId} instance={InstanceId} resolved={DataBlob}",
|
||||
playerId, room.RoomId, room.RoomInstanceId, resolved);
|
||||
room.DataBlob = resolved;
|
||||
presence.SetRoom(playerId, room);
|
||||
}
|
||||
}
|
||||
|
||||
log.LogInformation(
|
||||
"[player-heartbeat] player={PlayerId} room={RoomId} instance={InstanceId} subRoom={SubRoomId} location={Location} photon={PhotonRoomId} dataBlob={DataBlob}",
|
||||
playerId, room.RoomId, room.RoomInstanceId, room.SubRoomId, room.Location, room.PhotonRoomId, room.DataBlob);
|
||||
return room;
|
||||
}
|
||||
|
||||
/// <summary>Build a RoomInstance that matches the shape
|
||||
/// <c>/goto/room/DormRoom</c> would have produced for this player
|
||||
/// — used as a heartbeat fallback when presence is empty (boot
|
||||
/// race, replica restart, Redis TTL expiry). Same RoomInstanceId
|
||||
/// math, same PhotonRoomId pattern, same location GUID; the
|
||||
/// watch's RoomInstancesAreDifferent finds the fallback identical
|
||||
/// to its own cached state, so no out-of-sync. Returns null only
|
||||
/// if we can't find the player's dorm row in the DB.</summary>
|
||||
private async Task<RoomInstanceDto?> BuildDefaultDormPresenceAsync(int playerId)
|
||||
{
|
||||
var dormRow = await db.Rooms.AsNoTracking()
|
||||
.Where(r => r.IsDormRoom && r.CreatorPlayerId == playerId)
|
||||
.Select(r => new { r.Id, r.CurrentDataBlobName, r.LocationReplicationId })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Fall back to the canonical dorm template row if the player
|
||||
// doesn't yet have their own dorm record — same id space
|
||||
// /goto uses.
|
||||
long roomId = dormRow?.Id ?? 1;
|
||||
string dataBlob = dormRow?.CurrentDataBlobName ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(dataBlob))
|
||||
dataBlob = await ResolveDataBlobForPresenceAsync(roomId, playerId);
|
||||
|
||||
return new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = roomId * 100_000L,
|
||||
RoomId = roomId,
|
||||
SubRoomId = 0,
|
||||
Location = dormRow?.LocationReplicationId ?? "76d98498-60a1-430c-ab76-b54a29b7a163",
|
||||
PhotonRegionId = "us",
|
||||
PhotonRoomId = $"^dormroom_p{playerId}",
|
||||
Name = "DormRoom",
|
||||
MaxCapacity = 8,
|
||||
IsFull = false,
|
||||
IsPrivate = true,
|
||||
IsInProgress = true,
|
||||
DataBlob = dataBlob ?? string.Empty,
|
||||
EventId = 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> ResolveDataBlobForPresenceAsync(long roomId, int playerId)
|
||||
{
|
||||
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return string.Empty;
|
||||
|
||||
if (room.IsDormRoom)
|
||||
{
|
||||
var dormBlob = await db.DormStates.AsNoTracking()
|
||||
.Where(d => d.PlayerId == playerId)
|
||||
.Select(d => d.CurrentDataBlobName)
|
||||
.FirstOrDefaultAsync();
|
||||
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||||
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
|
||||
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape of RecNet.PlayerPresence (RVA 0x1458920). Lives here as a
|
||||
/// local DTO; same fields as PlayerPresenceController.PlayerPresenceDto.
|
||||
/// </summary>
|
||||
public class PresenceDto
|
||||
{
|
||||
[JsonPropertyName("playerId")]
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
[JsonPropertyName("statusVisibility")]
|
||||
public int StatusVisibility { get; set; }
|
||||
|
||||
[JsonPropertyName("deviceClass")]
|
||||
public int DeviceClass { get; set; }
|
||||
|
||||
[JsonPropertyName("vrMovementMode")]
|
||||
public int VrMovementMode { get; set; }
|
||||
|
||||
[JsonPropertyName("roomInstance")]
|
||||
public object? RoomInstance { get; set; } = null;
|
||||
|
||||
// 2020.12 PlayerPresence.Deserialize reads these as required keys.
|
||||
[JsonPropertyName("isOnline")]
|
||||
public bool IsOnline { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("appVersion")]
|
||||
public int AppVersion { get; set; } = 20201210;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Match;
|
||||
|
||||
/// <summary>
|
||||
/// match.rec.net/player — bulk player-presence query used by
|
||||
/// <c>RecNet.Matchmaking.RefreshCachedPlayerPresences</c>.
|
||||
///
|
||||
/// URL pattern verified against
|
||||
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Matchmaking.txt
|
||||
/// lines 3565-3568:
|
||||
/// <c>String.Join("&", ids)</c> → <c>String.Concat("player?", joined)</c>.
|
||||
/// So the request URL is literally <c>player?{id1}&{id2}&{id3}...</c>
|
||||
/// — every query-string KEY is one account id, with no value. The previous
|
||||
/// <c>[FromQuery(Name="id")] int[] ids</c> binding only matched
|
||||
/// <c>?id=X&id=Y</c> and never picked up any of the watch's bare-id
|
||||
/// requests, which is why every other player rendered as offline: with no
|
||||
/// ids parsed, the response was <c>[]</c> and IsOnline (which checks
|
||||
/// <c>roomInstance != null</c>) defaulted to false.
|
||||
///
|
||||
/// Response shape: <c>List<PlayerPresence></c> — keys verified by
|
||||
/// disassembling <c>RecNet.PlayerPresence.Deserialize</c>:
|
||||
/// <c>"playerId"</c> (int, required)
|
||||
/// <c>"statusVisibility"</c> (int, required)
|
||||
/// <c>"deviceClass"</c> (int, required)
|
||||
/// <c>"vrMovementMode"</c> (only read when deviceClass == 1)
|
||||
/// <c>"roomInstance"</c> (object, optional)
|
||||
///
|
||||
/// <c>PlayerPresence.IsOnline</c> returns true iff
|
||||
/// <c>roomInstance</c> is non-null — so we surface the live RoomInstance
|
||||
/// from <see cref="PlayerPresenceService"/> rather than always sending
|
||||
/// null. Players who haven't /goto'd anywhere still report offline.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PlayerPresenceController(PlayerPresenceService presence) : ControllerBase
|
||||
{
|
||||
[HttpGet("/player")]
|
||||
public ActionResult<List<PlayerPresenceDto>> GetPresences()
|
||||
{
|
||||
// The watch sends two URL flavours for this endpoint:
|
||||
// 1. Bulk: <c>/player?{id1}&{id2}&{id3}</c> — bare keys,
|
||||
// from RefreshCachedPlayerPresences (Matchmaking.txt:3565).
|
||||
// 2. Single: <c>/player?id={N}</c> — observed in the live trace
|
||||
// from a different code path (probably the per-player presence
|
||||
// probe). Both must work; an empty response makes
|
||||
// PUNNetworkManager skip Photon region setup, which then
|
||||
// crashes <c>PingPhotonRegionsInternal</c> with
|
||||
// "An item with the same key has already been added. Key: none"
|
||||
// and leaves the player on a black screen post-dorm-load.
|
||||
//
|
||||
// We accept ANY query parameter whose value parses as a long, plus
|
||||
// any bare key that does. Same dedup → same DB lookup → same
|
||||
// response shape regardless.
|
||||
var ids = new HashSet<long>();
|
||||
foreach (var (key, values) in Request.Query)
|
||||
{
|
||||
if (long.TryParse(key, out var fromKey) && fromKey > 0)
|
||||
ids.Add(fromKey);
|
||||
foreach (var v in values)
|
||||
{
|
||||
if (long.TryParse(v, out var fromValue) && fromValue > 0)
|
||||
ids.Add(fromValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Caller's own id gets the FULL RoomInstance (photonRoomId +
|
||||
// dataBlob) so the watch's cached PlayerPresence for itself
|
||||
// matches what /player/heartbeat returns. The watch calls
|
||||
// /player/?id=<self>&id=<friend>&... at boot via
|
||||
// RefreshCachedPlayerPresences and then caches each entry; if
|
||||
// self's entry is redacted (photonRoomId="" / dataBlob="") but
|
||||
// heartbeat later returns the full values, RoomInstance.
|
||||
// RoomInstancesAreDifferent fires → "presence heartbeat
|
||||
// response indicates local presence is out-of-sync!" →
|
||||
// OnPresenceHeartbeatResponse tears the player out of the
|
||||
// current room and re-joins, killing any in-flight save flow
|
||||
// mid-coroutine. Only redact for OTHER players where the
|
||||
// privacy concern actually applies.
|
||||
var callerId = this.CurrentPlayerId();
|
||||
var result = ids.Select(id =>
|
||||
{
|
||||
var room = presence.GetRoom(id);
|
||||
// IsOnline is derived from current room presence: a player
|
||||
// who has heartbeated recently has a non-null RoomInstance
|
||||
// in PlayerPresenceService; one who hasn't shows up as
|
||||
// null. Without this, IsOnline defaulted to `true` and the
|
||||
// watch's friends list showed every queried account as
|
||||
// Online regardless of actual session state — verified live
|
||||
// (six accounts shown Online despite none being connected).
|
||||
var isOnline = room is not null;
|
||||
return new PlayerPresenceDto
|
||||
{
|
||||
PlayerId = (int)id,
|
||||
// StatusVisibility is the player's *preference* (would
|
||||
// they like to appear online if they were online?). For
|
||||
// offline players we still report Visible so when they
|
||||
// come back the friend's cached preference is correct,
|
||||
// and IsOnline=false is what gates the "Online" badge.
|
||||
StatusVisibility = 1, // Visible
|
||||
DeviceClass = 0, // Desktop / unknown — not VR
|
||||
VrMovementMode = 0,
|
||||
RoomInstance = id == callerId ? room : RoomInstanceDto.Redact(room),
|
||||
IsOnline = isOnline,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class PlayerPresenceDto
|
||||
{
|
||||
[JsonPropertyName("playerId")]
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
/// <summary>PlayerStatusVisibility enum (int):
|
||||
/// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly.</summary>
|
||||
[JsonPropertyName("statusVisibility")]
|
||||
public int StatusVisibility { get; set; }
|
||||
|
||||
/// <summary>RecNet.DeviceClass enum (int).</summary>
|
||||
[JsonPropertyName("deviceClass")]
|
||||
public int DeviceClass { get; set; }
|
||||
|
||||
/// <summary>VRMovementMode enum (int) — only consulted when
|
||||
/// deviceClass == 1.</summary>
|
||||
[JsonPropertyName("vrMovementMode")]
|
||||
public int VrMovementMode { get; set; }
|
||||
|
||||
/// <summary>Read by Util.GetObjectKey<RoomInstance> — must
|
||||
/// be a nested object (or null), NOT a string. Empty string ""
|
||||
/// triggers
|
||||
/// <c>InvalidCastException: Unable to cast object of type 'String'
|
||||
/// to type 'Dictionary'2'</c>. <c>null</c> means "player not in
|
||||
/// any room" → IsOnline = false.</summary>
|
||||
[JsonPropertyName("roomInstance")]
|
||||
public object? RoomInstance { get; set; } = null;
|
||||
|
||||
// 2020.12 PlayerPresence.Deserialize (EFAJBHGDIDD.PPGFHEDFBEA) reads
|
||||
// these as required keys after roomInstance. Missing → KeyNotFoundException
|
||||
// → "RecNet presence heartbeat failed: Malformed Response" spam.
|
||||
[JsonPropertyName("isOnline")]
|
||||
public bool IsOnline { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("appVersion")]
|
||||
public int AppVersion { get; set; } = 20201210;
|
||||
}
|
||||
Reference in New Issue
Block a user