mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user