From e404ed8352f11a08a8a039b783a2815caa093cfc Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:55:43 +0300 Subject: [PATCH] Fix live room and account updates --- .../API/Account/AccountApiController.cs | 39 ++-------- .../API/Players/V2/PlayersController.cs | 3 +- .../Accounts/AccountsController.cs | 14 ++-- .../Controllers/Admin/AdminController.cs | 71 +++++++++++++++++++ .../Controllers/Cdn/CdnController.cs | 37 +++++++++- .../Controllers/Match/GoToController.cs | 49 ++++++++++--- .../Services/NotificationService.cs | 44 ++++++++++++ .../Services/RoomDataBlobService.cs | 25 +++++-- DorkNet.Server/admin-ui/src/pages/Players.tsx | 21 ++++++ 9 files changed, 241 insertions(+), 62 deletions(-) diff --git a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs index 68a9c34..62b036c 100644 --- a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs +++ b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs @@ -128,6 +128,8 @@ public class AccountApiController( var name = (displayName ?? value ?? string.Empty).Trim(); if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" }); await playerService.UpdateDisplayNameAsync(p.Id, name); + var updated = await playerService.GetByIdAsync(p.Id); + if (updated is not null) await notifications.AccountChanged(updated); return Ok(new RecNetResult { Success = true, Error = string.Empty }); } @@ -153,6 +155,7 @@ public class AccountApiController( if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" }); p.Username = newName; await db.SaveChangesAsync(); + await notifications.AccountChanged(p); return Ok(new RecNetResult { Success = true, Error = string.Empty }); } @@ -222,44 +225,10 @@ public class AccountApiController( "[api-account] profile image set: player={PlayerId} -> {Name}", player.Id, player.ProfileImageName ?? ""); - await NotifyAccountImageChangedAsync(player); + await notifications.AccountChanged(player); return Ok(new RecNetResult { Success = true, Error = string.Empty }); } - private Task NotifyAccountImageChangedAsync(PlayerEntity player) - { - var account = new RecNetAccount - { - AccountId = (int)player.Id, - RawUsername = player.Username, - Username = player.Username, - DisplayName = player.DisplayName ?? player.Username, - ProfileImage = player.ProfileImageName ?? string.Empty, - TreatAsJunior = player.IsJunior, - HasBirthday = true, - Platforms = 1, - }; - var selfAccount = new RecNetSelfAccount - { - AccountId = account.AccountId, - RawUsername = account.RawUsername, - Username = account.Username, - DisplayName = account.DisplayName, - ProfileImage = account.ProfileImage, - TreatAsJunior = account.TreatAsJunior, - HasBirthday = account.HasBirthday, - Platforms = account.Platforms, - Email = player.Email ?? string.Empty, - Phone = player.Phone ?? string.Empty, - Birthday = player.Birthday, - JuniorState = player.IsJunior ? 1 : 0, - ParentAccountId = null, - }; - return Task.WhenAll( - notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account), - notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount)); - } - [HttpGet("api/account/v1/namegen")] [HttpGet("api/account/v1/namegeneration")] public IActionResult NameGen() => Ok(new diff --git a/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs index 38c377d..4c6bfd1 100644 --- a/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs +++ b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs @@ -47,7 +47,7 @@ public class PlayersController(PlayerService playerService, NotificationService if (!updated) return NotFound(); var player = await playerService.GetByIdAsync(CurrentPlayerId); - await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!)); + await notificationService.AccountChanged(player!); return Ok(PlayerService.ToProfile(player!)); } @@ -63,6 +63,7 @@ public class PlayersController(PlayerService playerService, NotificationService await playerService.UpdateBioAsync(id, req.Bio); var player = await playerService.GetByIdAsync(id); + await notificationService.AccountChanged(player!); return Ok(PlayerService.ToProfile(player!)); } } diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs index 8c8369a..37512a9 100644 --- a/DorkNet.Server/Controllers/Accounts/AccountsController.cs +++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs @@ -393,19 +393,10 @@ public class AccountsController( logger.LogInformation( "[accounts] profile image set: player={PlayerId} → {Name}", player.Id, player.ProfileImageName ?? ""); - await NotifyAccountImageChangedAsync(player); + await notifications.AccountChanged(player); return Ok(new RecNetResult { Success = true, Error = string.Empty }); } - private Task NotifyAccountImageChangedAsync(PlayerEntity player) - { - var account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName); - var selfAccount = BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName); - return Task.WhenAll( - notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account), - notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount)); - } - private static int AgeYears(DateTime birthday) { var today = DateTime.UtcNow.Date; @@ -440,6 +431,7 @@ public class AccountsController( if (player is null) return Unauthorized(); await playerService.UpdateUsernameAsync(player.Id, username.Trim()); var updated = await playerService.GetByIdAsync(player.Id); + if (updated is not null) await notifications.AccountChanged(updated); return Ok(new { success = true, @@ -468,6 +460,8 @@ public class AccountsController( var player = await GetCurrentPlayerAsync(); if (player is null) return Unauthorized(); await playerService.UpdateDisplayNameAsync(player.Id, displayName.Trim()); + var updated = await playerService.GetByIdAsync(player.Id); + if (updated is not null) await notifications.AccountChanged(updated); return Ok(new { success = true, error = "" }); } diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index c2f1e60..42fd877 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1046,6 +1046,7 @@ public class AdminController( } await LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}"); await db.SaveChangesAsync(); + await NotifyRoomRolesChangedAsync(id, body.PlayerId, "AdminRoleGrant"); return Ok(new { ok = true }); } @@ -1060,6 +1061,7 @@ public class AdminController( db.RoomRoles.Remove(row); await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}"); await db.SaveChangesAsync(); + await NotifyRoomRolesChangedAsync(id, playerId, "AdminRoleRevoke"); return Ok(new { ok = true, deleted = 1 }); } @@ -1099,9 +1101,44 @@ public class AdminController( await LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}"); await db.SaveChangesAsync(); + await NotifyRoomRolesChangedAsync(id, previousOwner, "AdminOwnerTransfer"); + await NotifyRoomRolesChangedAsync(id, body.NewCreatorPlayerId, "AdminOwnerTransfer"); return Ok(new { ok = true }); } + /// Push fresh RoomDetails after role/owner mutations. The + /// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts, + /// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom + /// stores a replacement. + private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return; + var scenes = await db.RoomScenes + .Where(s => s.RoomId == roomId) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var roles = await db.RoomRoles + .Where(r => r.RoomId == roomId) + .ToListAsync(); + + var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController + .BuildRoomDetails(room, scenes, roles: roles); + + var recipients = onlinePresence.OnlinePlayerIds() + .Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId) + .Append(changedPlayerId) + .Distinct() + .ToList(); + + adminLogger.LogInformation( + "[admin-room-role] pushing room details room={RoomId} reason={Reason} changedPlayer={PlayerId} recipients={RecipientCount}", + roomId, reason, changedPlayerId, recipients.Count); + + foreach (var pid in recipients) + await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, payload); + } + /// GET api/admin/v1/rooms/{id}/instances — active /// Photon instances for one specific room. Subset of the global /// /instances endpoint pre-filtered to one room; the admin @@ -1854,6 +1891,32 @@ public class AdminController( return Ok(new { id, level = lvl, xp }); } + public sealed record SetLevelRequest(int Level); + + /// Set a player's level directly from the admin console. + /// XP is moved to the minimum value for that level so the next normal + /// XP grant does not snap the row back to the curve-derived level. + [HttpPost("players/{id:long}/level")] + public async Task SetLevel(long id, [FromBody] SetLevelRequest body) + { + var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id); + if (p is null) return NotFound(); + + var newLevel = Math.Clamp(body.Level, 1, 100); + var previous = p.Level; + p.Level = newLevel; + p.XP = MinimumXpForLevel(newLevel); + + await LogAsync("set_level", "player", id, $"{previous} -> {newLevel}"); + await db.SaveChangesAsync(); + await notifications.AccountChanged(p); + await notifications.NotifyAsync(id, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "AdminLevelSet", Level = p.Level, XP = p.XP, PreviousLevel = previous }); + + return Ok(new { p.Id, p.Level, p.XP }); + } + /// Insert one row per cheer Type /// (0=General, 1=Helpful, 2=GreatHost, 3=Sportsman, 4=Creative, /// 5=Credit) so the player has at least one received cheer in every @@ -1950,6 +2013,7 @@ public class AdminController( p.DisplayName = body.DisplayName.Trim(); await LogAsync("set_display_name", "player", id, p.DisplayName); await db.SaveChangesAsync(); + await notifications.AccountChanged(p); return Ok(new { p.Id, p.DisplayName }); } @@ -1979,9 +2043,16 @@ public class AdminController( p.Username = name; await LogAsync("set_username", "player", id, $"{previous} -> {name}"); await db.SaveChangesAsync(); + await notifications.AccountChanged(p); return Ok(new { p.Id, p.Username }); } + private static int MinimumXpForLevel(int level) + { + var normalized = Math.Max(1, level); + return 100 * (normalized - 1) * (normalized - 1); + } + private sealed class InventoryEntry { [System.Text.Json.Serialization.JsonPropertyName("itemId")] diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index 4045c9f..795bb8e 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -351,12 +351,31 @@ public class CdnController( } if (s3Bytes is { Length: > 0 }) { + var overlaidRoomRoleData = false; + if (await ShouldOverlayRroRoomRoleDataAsync(fileName)) + { + try + { + s3Bytes = roomDataBlob.OverlayAllPermsRoleData(s3Bytes); + overlaidRoomRoleData = true; + logger.LogInformation("[cdn] overlaid RRO room-role permissions host={Host} file={File} bytes={Bytes}", + Request.Host.Host, fileName, s3Bytes.Length); + } + catch (Exception ex) + { + logger.LogWarning(ex, "[cdn] failed to overlay RRO room-role permissions for {File}; serving original blob", + fileName); + } + } + logger.LogInformation("[cdn] s3 hit host={Host} file={File} bytes={Bytes}", Request.Host.Host, fileName, s3Bytes.Length); // Allow CF edge to cache for an hour. RoomDataBlobs are // content-addressed by hash so the same BlobName never // serves different bytes — safe to cache aggressively. - Response.Headers.CacheControl = "public, max-age=3600"; + Response.Headers.CacheControl = overlaidRoomRoleData + ? "public, max-age=60" + : "public, max-age=3600"; signatures.AddContentSignature(Response, s3Bytes); return new FileContentResult(s3Bytes, MimeFromName(fileName)); } @@ -381,6 +400,22 @@ public class CdnController( return new FileContentResult(fallbackBlob, "application/octet-stream"); } + private async Task ShouldOverlayRroRoomRoleDataAsync(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) return false; + + if (await db.Rooms.AsNoTracking().AnyAsync(r => + r.IsAGRoom && r.CurrentDataBlobName == fileName)) + return true; + + return await ( + from scene in db.RoomScenes.AsNoTracking() + join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id + where room.IsAGRoom && scene.DataBlobName == fileName + select scene.Id + ).AnyAsync(); + } + /// /// Apply the watch's ?cropSquare=1 + ?width=N transforms /// per-request on the way out and sign the *resulting* bytes — the diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 2049f24..49ba54e 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -586,9 +586,11 @@ public class GoToController( /// POST /goto/player/{playerId} — 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 PUBLIC rooms: mirror the target's live RoomInstance. The + /// 2020.12 client reconciles joined players against the exact + /// roomInstanceId/photonRoomId pair it got from matchmaking; rebuilding + /// a fresh instance here can leave the caller in a different Photon + /// shard than the target and null-ref during the follow-join callback. /// /// For PRIVATE rooms: this route must NOT bypass the invite list. /// Earlier we mirrored the target's RoomInstance verbatim under @@ -631,20 +633,49 @@ public class GoToController( } // Allowed — mirror the target's RoomInstance so we land // in the same Photon match. - resp = new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = theirRoom }; + resp = new MatchmakingResponseDto + { + ErrorCode = 0, + RoomInstance = CloneRoomInstance(theirRoom), + }; } else { - var roomName = await db.Rooms - .Where(r => r.Id == theirRoom.RoomId) - .Select(r => r.Name) - .FirstOrDefaultAsync(); - resp = await BuildResponseAsync(roomName ?? "DormRoom"); + // Public follow-joins still need to land in the target's + // specific live instance, not just the same room name. This + // preserves sub-room and Photon shard identity for PUN's + // player-enter reconciliation. + resp = new MatchmakingResponseDto + { + ErrorCode = 0, + RoomInstance = CloneRoomInstance(theirRoom), + }; } await RecordResponseAsync(resp); return Ok(resp); } + private static RoomInstanceDto CloneRoomInstance(RoomInstanceDto room) => new() + { + RoomInstanceId = room.RoomInstanceId, + RoomId = room.RoomId, + SubRoomId = room.SubRoomId, + Location = room.Location, + PhotonRegionId = room.PhotonRegionId, + PhotonRoomId = room.PhotonRoomId, + MaxCapacity = room.MaxCapacity, + IsFull = room.IsFull, + IsPrivate = room.IsPrivate, + IsInProgress = room.IsInProgress, + DataBlob = room.DataBlob, + EventId = room.EventId, + Name = room.Name, + RoomInstanceType = room.RoomInstanceType, + ClubId = room.ClubId, + RoomCode = room.RoomCode, + EncryptVoiceChat = room.EncryptVoiceChat, + }; + /// Record the just-built response against the bearer-token /// player so the next heartbeat reflects the room they joined. /// Anonymous /goto calls (no JWT) skip recording — the heartbeat is diff --git a/DorkNet.Server/Services/NotificationService.cs b/DorkNet.Server/Services/NotificationService.cs index 1a68d34..c5a22b6 100644 --- a/DorkNet.Server/Services/NotificationService.cs +++ b/DorkNet.Server/Services/NotificationService.cs @@ -1,5 +1,7 @@ using DorkNet.Models.Notification; using DorkNet.Server.Controllers.Match; +using DorkNet.Models.Auth; +using DorkNet.Server.Data.Entities; using DorkNet.Server.Hubs; using Microsoft.AspNetCore.SignalR; using System.Text.Json; @@ -158,6 +160,48 @@ public class NotificationService( } } + /// Push fresh account payloads to the affected player's + /// watch. The 2020 client caches Account/SelfAccount objects in memory; + /// without this, username and display-name edits persist server-side + /// but the current session keeps rendering the stale cached names until + /// a full relog rebuilds the account cache. + public Task AccountChanged(PlayerEntity player) + { + var account = new RecNetAccount + { + AccountId = (int)player.Id, + RawUsername = player.Username, + Username = player.Username, + DisplayName = string.IsNullOrWhiteSpace(player.DisplayName) ? player.Username : player.DisplayName, + ProfileImage = player.ProfileImageName ?? string.Empty, + TreatAsJunior = player.IsJunior, + HasBirthday = true, + Platforms = 1, + CreatedAt = player.CreatedAt, + }; + var selfAccount = new RecNetSelfAccount + { + AccountId = account.AccountId, + RawUsername = account.RawUsername, + Username = account.Username, + DisplayName = account.DisplayName, + ProfileImage = account.ProfileImage, + TreatAsJunior = account.TreatAsJunior, + HasBirthday = account.HasBirthday, + Platforms = account.Platforms, + CreatedAt = account.CreatedAt, + Email = player.Email ?? string.Empty, + Phone = player.Phone ?? string.Empty, + Birthday = player.Birthday ?? new DateTime(2000, 1, 1), + JuniorState = player.IsJunior ? 1 : 0, + ParentAccountId = null, + }; + + return Task.WhenAll( + NotifyAsync(player.Id, PushNotificationId.SubscriptionUpdateProfile, account), + NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount)); + } + /// Push a notification to every currently-connected /// player. Used for global announcements and admin broadcasts. public async Task BroadcastAsync(PushNotificationId id, object? msg = null) diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index 986dc3d..9211c3f 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -30,10 +30,28 @@ public class RoomDataBlobService /// startup beats rebuilding on every download request. /// private readonly byte[] _allPermsBlob = BuildAllPermsBlob(); + private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData(); public byte[] GetDefaultBlob() => _allPermsBlob; + public byte[] OverlayAllPermsRoleData(byte[] existingBlob) + { + var msg = PersistedRoomData.Parser.ParseFrom(existingBlob); + msg.RoomRoleData = _allPermsRoleData.Clone(); + return msg.ToByteArray(); + } + private static byte[] BuildAllPermsBlob() + { + var msg = new PersistedRoomData + { + RoomRoleData = BuildAllPermsRoleData(), + }; + + return msg.ToByteArray(); + } + + private static RoomRoleCollectionData BuildAllPermsRoleData() { // OverridableBoolData(overrides=true, inner_value=true) — used for // every Can* field on the role. @@ -101,11 +119,6 @@ public class RoomDataBlobService var collection = new RoomRoleCollectionData(); collection.RoomRoles.Add(role); - var msg = new PersistedRoomData - { - RoomRoleData = collection, - }; - - return msg.ToByteArray(); + return collection; } } diff --git a/DorkNet.Server/admin-ui/src/pages/Players.tsx b/DorkNet.Server/admin-ui/src/pages/Players.tsx index 0d50a98..7d06ad8 100644 --- a/DorkNet.Server/admin-ui/src/pages/Players.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Players.tsx @@ -391,6 +391,7 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v const [currType, setCurrType] = useState(2); const [currAmount, setCurrAmount] = useState(1000); const [xp, setXp] = useState(1000); + const [targetLevel, setTargetLevel] = useState(data.level); const [itemId, setItemId] = useState(''); const [itemQty, setItemQty] = useState(1); @@ -424,6 +425,26 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v >Grant XP +
+

Set level

+

Moves XP to the floor for that level.

+
+ setTargetLevel(parseInt(e.target.value || '1'))} + className="input" + /> +
+ +
+

Unlock cheer badges