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; /// /// 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). /// [ApiController] public class MatchPlayerController( PlayerPresenceService presence, NotificationService notifications, RoomService rooms, PrivateInstanceService privateInstances, JoinTimeoutService joinTimeouts, PresenceBroadcastService presenceFanout, IConfiguration config, DorkNetDbContext db, ILogger log) : ControllerBase { /// /// POST /player/login — the 2020 watch starts every session by /// posting here with a fresh LoginLock 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. /// [HttpPost("/player/login")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock) => await RecordLoginLockAsync(loginLock, "player-login"); /// /// POST /player/exclusivelogin — newer clients use this instead /// of /player/login and include a /// TakeOverExclusiveSession form value. The lock semantics are /// otherwise the same: the latest LoginLock wins for this account. /// [HttpPost("/player/exclusivelogin")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task PlayerExclusiveLogin( [FromForm(Name = "LoginLock")] string? loginLock, [FromForm(Name = "TakeOverExclusiveSession")] bool? takeOverExclusiveSession) => await RecordLoginLockAsync(loginLock, "player-exclusive-login", takeOverExclusiveSession); private async Task 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}; kicking old session(s) via ModerationKick push", logScope, playerId, takeOverExclusiveSession); // 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()); } /// /// 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. /// [HttpPost("/player/logout")] public async Task PlayerLogout() { var playerId = TryGetCurrentPlayerId(); if (playerId != 0) { joinTimeouts.Cancel(playerId, "logout"); presence.Clear(playerId); await presenceFanout.PublishOfflineAsync(playerId); } return Ok(new { success = true, error = "" }); } /// /// POST /player/notifydisconnect — fired by /// RecNet.Matchmaking.NotifyPlayerDisconnected(int otherPlayerId) /// (Matchmaking.cs:326). Body is form-urlencoded /// otherPlayerId=<id>. 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). /// [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(); } /// /// 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. /// [HttpPost("/player/heartbeat")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task> 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> 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> 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 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 GetPlayerPrefIntAsync(long playerId, string key, int fallback) => int.TryParse(await GetPlayerPrefAsync(playerId, key), out var v) ? v : fallback; /// /// GET /player/avoidjuniors — returns a primitive boolean. /// Verified against /// Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:7728 (URL) and 7737 /// (PromiseExtensions.ExpectPrimitiveResponse) — the watch /// expects a bare JSON true/false, not an array or /// object. Returning [] 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. /// [HttpGet("/player/avoidjuniors")] public async Task> GetAvoidJuniors() { var playerId = TryGetCurrentPlayerId(); var stored = await GetPlayerPrefAsync(playerId, PrefAvoidJuniors); return Ok(bool.TryParse(stored, out var v) && v); } /// PUT /player/avoidjuniors — body /// avoidJuniors=true|false (Matchmaking.txt:7883). Persisted /// per-player in PlayerSettings and read back by /// . Returns the new value as a JSON /// primitive so the watch's ExpectPrimitiveResponse is happy. [HttpPut("/player/avoidjuniors")] [HttpPost("/player/avoidjuniors")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task> 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); } /// POST /player/photonregionpings — 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: region=eu&ping=42&region=us&ping=80&... /// (one (region, ping) pair per row, repeated). Persists the /// freshest measurement per (player, region) pair in /// '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. // 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(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>)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 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; } /// Build a RoomInstance that matches the shape /// /goto/room/DormRoom 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 /goto'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 RoomInstanceId = roomId * 100_000, but /// GoToController stamps the dorm with /// EnsureForDormAsync's id (0xDD0000 ^ …) — 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 + /// path /// /goto/room/DormRoom uses, so the id math can never drift /// again. Returns null only for anonymous callers. private async Task 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 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 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); } } /// /// Wire shape of RecNet.PlayerPresence (RVA 0x1458920). Lives here as a /// local DTO; same fields as PlayerPresenceController.PlayerPresenceDto. /// 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"; }