mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
e33145307f
Most pushes were still shaped for the 2020 watch, so the 2023 client parsed them as empty or dropped them without any error. - RelationshipChanged carries the relationship row, to both players, on friend request / accept / remove / add and on favorite / mute / ignore. Friend requests and accepts also land in the notifications inbox. - ModerationKick uses the client's flat moderation block, with the reason in VoteKickReason (the only field the dialog shows). - Bans, account deletion, password resets and second-session takeover are a Logout with a numeric reason instead of a kick to the dorm; the old string reason failed the client's parse. - SubscribeToPlayers is honoured: each connection joins a presence group per subscribed player, and presence (only presence) reaches subscribers as well as friends. The hub allows 180 s of client silence, since the client reconnects at most 3 times per login. - Admin broadcast sends its text as a coach message and can start the client's own maintenance countdown; ServerMaintenance only reads StartsInMinutes. - ConsumableMapping, StorefrontBalance, gift, player event, level-up, RoomUpdate, profile-image and PlayerLeftChat pushes carry the client's DTOs. "Join rejected" is the client's RequestGameInvite message. Contract in docs/recroom-2023-notifications.md; push frames are checked by NotificationContractTests through a capturing hub context.
645 lines
30 KiB
C#
645 lines
30 KiB
C#
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,
|
||
RoomService rooms,
|
||
PrivateInstanceService privateInstances,
|
||
JoinTimeoutService joinTimeouts,
|
||
PresenceBroadcastService presenceFanout,
|
||
IConfiguration config,
|
||
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)
|
||
=> await RecordLoginLockAsync(loginLock, "player-login");
|
||
|
||
/// <summary>
|
||
/// POST <c>/player/exclusivelogin</c> — newer clients use this instead
|
||
/// of <c>/player/login</c> and include a
|
||
/// <c>TakeOverExclusiveSession</c> form value. The lock semantics are
|
||
/// otherwise the same: the latest LoginLock wins for this account.
|
||
/// </summary>
|
||
[HttpPost("/player/exclusivelogin")]
|
||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||
public async Task<IActionResult> PlayerExclusiveLogin(
|
||
[FromForm(Name = "LoginLock")] string? loginLock,
|
||
[FromForm(Name = "TakeOverExclusiveSession")] bool? takeOverExclusiveSession)
|
||
=> await RecordLoginLockAsync(loginLock, "player-exclusive-login", takeOverExclusiveSession);
|
||
|
||
private async Task<IActionResult> RecordLoginLockAsync(
|
||
string? loginLock,
|
||
string logScope,
|
||
bool? takeOverExclusiveSession = null)
|
||
{
|
||
var playerId = TryGetCurrentPlayerId();
|
||
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
|
||
{
|
||
presence.Clear(playerId);
|
||
presence.BeginBootDormGrace(playerId);
|
||
var replaced = presence.SetLock(playerId, loginLock);
|
||
if (replaced)
|
||
{
|
||
log.LogWarning(
|
||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
|
||
logScope, playerId, takeOverExclusiveSession);
|
||
// The new session hasn't opened its SignalR socket yet
|
||
// (negotiate fires AFTER /player/login on the boot
|
||
// sequence — verified in the live trace), so the Logout
|
||
// push (Reason SessionTakeover) reaches only the OLDER
|
||
// session, which shows the client's own "signed in
|
||
// elsewhere" message and returns to the boot scene.
|
||
await notifications.LogoutStaleSession(playerId);
|
||
}
|
||
}
|
||
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, and tell
|
||
/// friends right away — otherwise they keep seeing the player in their
|
||
/// last room until a bulk poll happens to notice.
|
||
/// </summary>
|
||
[HttpPost("/player/logout")]
|
||
public async Task<IActionResult> PlayerLogout()
|
||
{
|
||
var playerId = TryGetCurrentPlayerId();
|
||
if (playerId != 0)
|
||
{
|
||
joinTimeouts.Cancel(playerId, "logout");
|
||
presence.Clear(playerId);
|
||
await presenceFanout.PublishOfflineAsync(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,
|
||
// The 2023-03-21 client sends PascalCase PlayerId + RoomInstanceId
|
||
// instead of 2020's otherPlayerId, so the legacy binding logged null.
|
||
[FromForm(Name = "PlayerId")] long? playerId,
|
||
[FromForm(Name = "RoomInstanceId")] long? roomInstanceId)
|
||
{
|
||
var caller = TryGetCurrentPlayerId();
|
||
log.LogInformation(
|
||
"[player-notify-disconnect] caller={Caller} reported other={Other} instance={Instance} left",
|
||
caller, otherPlayerId ?? (int?)playerId, roomInstanceId);
|
||
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);
|
||
if (playerId != 0)
|
||
{
|
||
presence.MarkActive(playerId);
|
||
if (room is not null) presence.TouchRoom(playerId);
|
||
}
|
||
return Ok(new PresenceDto
|
||
{
|
||
PlayerId = playerId,
|
||
// Defaults: 0 = Everyone. Both come from the player's stored
|
||
// preference so a value set via /player/statusvisibility or
|
||
// /player/vrmovementmode survives the next heartbeat.
|
||
StatusVisibility = await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0),
|
||
DeviceClass = 0,
|
||
VrMovementMode = await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 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();
|
||
if (value is int v)
|
||
{
|
||
await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
|
||
// Friends only. The client does not apply this response to its own
|
||
// cached presence, and a PresenceUpdate about the local player runs
|
||
// the same apply-and-rejoin path as an out-of-sync heartbeat
|
||
// (Matchmaking.EGPABEDPEJO -> FLAOOBNJAAD, Matchmaking.txt:6917), so no self push.
|
||
await presenceFanout.PublishCurrentAsync(playerId);
|
||
}
|
||
var room = await GetPresenceRoomForResponseAsync(playerId);
|
||
return Ok(new PresenceDto
|
||
{
|
||
PlayerId = playerId,
|
||
StatusVisibility = value ?? await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0),
|
||
DeviceClass = 0,
|
||
VrMovementMode = await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 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();
|
||
if (value is int v) await SetPlayerPrefAsync(playerId, PrefVrMovementMode, v.ToString());
|
||
var room = await GetPresenceRoomForResponseAsync(playerId);
|
||
return Ok(new PresenceDto
|
||
{
|
||
PlayerId = playerId,
|
||
StatusVisibility = await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0),
|
||
DeviceClass = 0,
|
||
VrMovementMode = value ?? await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0),
|
||
RoomInstance = room,
|
||
});
|
||
}
|
||
|
||
// ── per-player matchmaking preferences ───────────────────────────
|
||
// These three were previously echo-only: the client's chosen value came
|
||
// back in the response and was then dropped, so the setting reverted on the
|
||
// next heartbeat and never survived a restart. They live in PlayerSettings
|
||
// (no schema change needed) and are read back wherever presence is built.
|
||
private const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
|
||
private const string PrefVrMovementMode = "pref:vrMovementMode";
|
||
private const string PrefAvoidJuniors = "pref:avoidJuniors";
|
||
|
||
private async Task SetPlayerPrefAsync(long playerId, string key, string value)
|
||
{
|
||
if (playerId == 0) return;
|
||
var row = await db.PlayerSettings
|
||
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
|
||
if (row is null)
|
||
{
|
||
db.PlayerSettings.Add(new DorkNet.Server.Data.Entities.PlayerSettingEntity
|
||
{
|
||
PlayerId = playerId, Key = key, Value = value,
|
||
});
|
||
}
|
||
else
|
||
{
|
||
row.Value = value;
|
||
}
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
private async Task<string?> GetPlayerPrefAsync(long playerId, string key)
|
||
{
|
||
if (playerId == 0) return null;
|
||
return await db.PlayerSettings
|
||
.Where(s => s.PlayerId == playerId && s.Key == key)
|
||
.Select(s => s.Value)
|
||
.FirstOrDefaultAsync();
|
||
}
|
||
|
||
private async Task<int> GetPlayerPrefIntAsync(long playerId, string key, int fallback)
|
||
=> int.TryParse(await GetPlayerPrefAsync(playerId, key), out var v) ? v : fallback;
|
||
|
||
/// <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 async Task<ActionResult<bool>> GetAvoidJuniors()
|
||
{
|
||
var playerId = TryGetCurrentPlayerId();
|
||
var stored = await GetPlayerPrefAsync(playerId, PrefAvoidJuniors);
|
||
return Ok(bool.TryParse(stored, out var v) && v);
|
||
}
|
||
|
||
/// <summary>PUT <c>/player/avoidjuniors</c> — body
|
||
/// <c>avoidJuniors=true|false</c> (Matchmaking.txt:7883). Persisted
|
||
/// per-player in PlayerSettings and read back by
|
||
/// <see cref="GetAvoidJuniors"/>. 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 async Task<ActionResult<bool>> SetAvoidJuniors([FromForm(Name = "avoidJuniors")] bool? value)
|
||
{
|
||
var playerId = TryGetCurrentPlayerId();
|
||
if (value is bool v)
|
||
await SetPlayerPrefAsync(playerId, PrefAvoidJuniors, v ? "true" : "false");
|
||
return 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 regions = Request.HasFormContentType
|
||
? Request.Form["region"].ToArray()
|
||
: Request.Query["region"].ToArray();
|
||
var pings = Request.HasFormContentType
|
||
? Request.Form["ping"].ToArray()
|
||
: Request.Query["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);
|
||
}
|
||
|
||
// The 2023-03-21 client does NOT use the repeated (region, ping) pair
|
||
// shape above. It sends one field per measured region, keyed by the
|
||
// region name with the ping as the value (us=42&eu=90&...). Against the
|
||
// pair parser both arrays came back empty, so no 2023 client ever
|
||
// registered a ping and region selection fell back to the default.
|
||
if (pairs.Count == 0)
|
||
{
|
||
var fields = Request.HasFormContentType
|
||
? (IEnumerable<KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues>>)Request.Form
|
||
: Request.Query;
|
||
foreach (var (key, value) in fields)
|
||
{
|
||
var name = key?.Trim();
|
||
if (string.IsNullOrEmpty(name)) continue;
|
||
if (name.Equals("region", StringComparison.OrdinalIgnoreCase) ||
|
||
name.Equals("ping", StringComparison.OrdinalIgnoreCase)) continue;
|
||
if (!int.TryParse(value.FirstOrDefault(), out var p)) continue;
|
||
pairs[name] = 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);
|
||
var pending = joinTimeouts.GetPending(playerId);
|
||
if (pending is { DeferPresenceCommit: true })
|
||
{
|
||
// Report the instance the player is joining — NOT null.
|
||
//
|
||
// Deferring the commit is about what everyone ELSE sees: presence
|
||
// stays uncommitted until the join is confirmed, so a failed join
|
||
// never leaves a ghost in the room. It was never meant to change
|
||
// what we tell the joining player about themselves.
|
||
//
|
||
// Returning null did exactly that, and the client treats it as the
|
||
// room vanishing mid-join: OnPresenceHeartbeatResponse XORs
|
||
// (cachedRoomInstance != null) against (serverRoomInstance != null),
|
||
// so a client holding the target while we say null fires
|
||
// "presence heartbeat response indicates local presence is
|
||
// out-of-sync", then "Attempting to go to Invalid RoomInstance"
|
||
// and cancels its own load ("OnPlayerPresenceUpdate is cancelling
|
||
// room load"). The join it aborted was the one we were waiting on,
|
||
// so it retried until a heartbeat happened to miss the window —
|
||
// entering a dorm took several attempts and looked like a load
|
||
// failure. The branch below documents this same XOR for the
|
||
// boot-race case; this one contradicted it.
|
||
//
|
||
// Echoing the pending target satisfies the XOR and matches what the
|
||
// client already has, while presence itself stays uncommitted.
|
||
log.LogInformation(
|
||
"[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; echoing target until join result",
|
||
playerId,
|
||
pending.Value.TargetRoom.RoomId,
|
||
pending.Value.TargetRoom.RoomInstanceId);
|
||
return pending.Value.TargetRoom;
|
||
}
|
||
|
||
if (room is null)
|
||
{
|
||
// The player went to the offline room (matchmake/none). Their
|
||
// client now holds RoomInstanceId -1, which the heartbeat compare
|
||
// treats as "no room" (HCHDEHIGEBE.GIICPDHJOOH, sign bit of the id;
|
||
// RecNet.Runtime/RecNet/Matchmaking.txt:18415-18437) and is only in
|
||
// sync with a null roomInstance. The dorm fallback below would
|
||
// read as out-of-sync and drag the player out of the offline room.
|
||
if (presence.IsInNoRoom(playerId))
|
||
{
|
||
presence.TouchNoRoom(playerId);
|
||
log.LogInformation(
|
||
"[player-heartbeat] player={PlayerId} online in no room (left via matchmake/none); returning null roomInstance",
|
||
playerId);
|
||
return null;
|
||
}
|
||
|
||
if (presence.IsBootDormGraceActive(playerId))
|
||
{
|
||
log.LogInformation(
|
||
"[player-heartbeat] player={PlayerId} in boot dorm grace; returning null roomInstance",
|
||
playerId);
|
||
return 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);
|
||
presence.MarkActive(playerId);
|
||
await presenceFanout.PublishCurrentAsync(playerId);
|
||
return fallback;
|
||
}
|
||
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
|
||
return null;
|
||
}
|
||
|
||
var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(
|
||
room.RoomId,
|
||
playerId,
|
||
room.RoomInstanceId,
|
||
room.SubRoomId,
|
||
room.IsPrivate);
|
||
if (!string.IsNullOrWhiteSpace(resolvedDataBlob)
|
||
&& !string.Equals(room.DataBlob, resolvedDataBlob, StringComparison.Ordinal))
|
||
{
|
||
log.LogWarning(
|
||
"[player-heartbeat] repairing stale presence dataBlob player={PlayerId} room={RoomId} instance={InstanceId} cached={CachedDataBlob} resolved={ResolvedDataBlob}",
|
||
playerId, room.RoomId, room.RoomInstanceId, room.DataBlob, resolvedDataBlob);
|
||
room.DataBlob = resolvedDataBlob;
|
||
presence.SetRoom(playerId, room);
|
||
}
|
||
presence.MarkActive(playerId);
|
||
presence.TouchRoom(playerId);
|
||
|
||
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).
|
||
///
|
||
/// CRITICAL: this MUST reproduce <c>/goto</c>'s RoomInstance
|
||
/// byte-for-byte (RoomInstanceId, PhotonRoomId, Location, DataBlob),
|
||
/// or the watch's RoomInstancesAreDifferent fires
|
||
/// "presence heartbeat response indicates local presence is
|
||
/// out-of-sync!" and boot-loops the player. The previous version
|
||
/// computed <c>RoomInstanceId = roomId * 100_000</c>, but
|
||
/// <c>GoToController</c> stamps the dorm with
|
||
/// <c>EnsureForDormAsync</c>'s id (<c>0xDD0000 ^ …</c>) — so the two
|
||
/// disagreed and every cache-miss heartbeat (e.g. right after a
|
||
/// server restart) kicked the player. We now go through the exact
|
||
/// same <see cref="RoomService.EnsurePersonalDormAsync"/> +
|
||
/// <see cref="PrivateInstanceService.EnsureForDormAsync"/> path
|
||
/// <c>/goto/room/DormRoom</c> uses, so the id math can never drift
|
||
/// again. Returns null only for anonymous callers.</summary>
|
||
private async Task<RoomInstanceDto?> BuildDefaultDormPresenceAsync(int playerId)
|
||
{
|
||
if (playerId == 0) return null;
|
||
|
||
// Same resolution /goto does: the caller's personal dorm row is
|
||
// the source of truth for RoomId + LocationReplicationId.
|
||
var personal = await rooms.EnsurePersonalDormAsync(playerId);
|
||
var roomId = personal.Id;
|
||
var location = personal.LocationReplicationId;
|
||
|
||
var dataBlob = await ResolveDataBlobForPresenceAsync(roomId, playerId);
|
||
var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
||
var photonRoomId = $"^dormroom_p{playerId}";
|
||
var dormInstanceName = await BuildDormInstanceNameAsync(playerId);
|
||
|
||
// EnsureForDormAsync owns the deterministic dorm instance-id
|
||
// formula; reusing it here guarantees the heartbeat's
|
||
// RoomInstanceId equals the one /goto handed the watch.
|
||
var dorm = await privateInstances.EnsureForDormAsync(
|
||
ownerPlayerId: playerId,
|
||
roomId: roomId,
|
||
subRoomId: 0,
|
||
baseName: dormInstanceName,
|
||
location: location,
|
||
dataBlob: dataBlob,
|
||
photonRegion: photonRegion,
|
||
maxCapacity: personal.MaxCapacity,
|
||
photonRoomId: photonRoomId);
|
||
|
||
return new RoomInstanceDto
|
||
{
|
||
RoomInstanceId = dorm.InstanceId,
|
||
RoomId = roomId,
|
||
SubRoomId = 0,
|
||
Location = location,
|
||
PhotonRegionId = photonRegion,
|
||
PhotonRoomId = photonRoomId,
|
||
Name = dormInstanceName,
|
||
MaxCapacity = personal.MaxCapacity,
|
||
IsFull = false,
|
||
IsPrivate = true,
|
||
IsInProgress = true,
|
||
DataBlob = dataBlob ?? string.Empty,
|
||
EventId = 0,
|
||
};
|
||
}
|
||
|
||
private async Task<string> BuildDormInstanceNameAsync(long ownerPlayerId)
|
||
{
|
||
var player = await db.Players.AsNoTracking()
|
||
.Where(p => p.Id == ownerPlayerId)
|
||
.Select(p => new { p.DisplayName, p.Username })
|
||
.FirstOrDefaultAsync();
|
||
var name = !string.IsNullOrWhiteSpace(player?.DisplayName)
|
||
? player!.DisplayName
|
||
: !string.IsNullOrWhiteSpace(player?.Username)
|
||
? player!.Username
|
||
: $"Player{ownerPlayerId}";
|
||
var cleaned = new string(name
|
||
.Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_')
|
||
.ToArray()).Trim('_');
|
||
return string.IsNullOrWhiteSpace(cleaned) ? $"Player{ownerPlayerId}_dorm" : $"{cleaned}_dorm";
|
||
}
|
||
|
||
private async Task<string> ResolveDataBlobForPresenceAsync(
|
||
long roomId,
|
||
int playerId,
|
||
long? roomInstanceId = null,
|
||
long? subRoomId = null,
|
||
bool isPrivateInstance = false)
|
||
{
|
||
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
|
||
if (room is null) return string.Empty;
|
||
|
||
if (room.IsDormRoom)
|
||
{
|
||
var dormBlob = await rooms.ResolveDormDataBlobNameAsync(playerId, room.Id);
|
||
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
|
||
}
|
||
|
||
if (isPrivateInstance && roomInstanceId is > 0)
|
||
{
|
||
var instanceBlob = await db.PrivateInstances.AsNoTracking()
|
||
.Where(instance => instance.Id == roomInstanceId && instance.RoomId == roomId)
|
||
.Select(instance => instance.DataBlob)
|
||
.FirstOrDefaultAsync();
|
||
if (!string.IsNullOrWhiteSpace(instanceBlob)) return instanceBlob;
|
||
}
|
||
|
||
if (subRoomId is >= 0)
|
||
{
|
||
var sceneBlob = await db.RoomScenes.AsNoTracking()
|
||
.Where(scene => scene.RoomId == roomId && scene.OrderIndex == subRoomId)
|
||
.Select(scene => scene.DataBlobName)
|
||
.FirstOrDefaultAsync();
|
||
if (!string.IsNullOrWhiteSpace(sceneBlob)) return sceneBlob;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||
|
||
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
|
||
|
||
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
||
}
|
||
}
|
||
|
||
/// <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")]
|
||
// 2023.03.21 client build version ("20230317"); must match the joining
|
||
// client's own version or it reports a version mismatch. Was a stale 2020
|
||
// value (20201210) left over from the December branch this forked from.
|
||
public string AppVersion { get; set; } = "20230317";
|
||
}
|