Fix live room and account updates

This commit is contained in:
Alexa
2026-06-01 20:55:43 +03:00
parent 35af6ff29e
commit e404ed8352
9 changed files with 241 additions and 62 deletions
@@ -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 ?? "<cleared>");
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
@@ -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!));
}
}
@@ -393,19 +393,10 @@ public class AccountsController(
logger.LogInformation(
"[accounts] profile image set: player={PlayerId} → {Name}",
player.Id, player.ProfileImageName ?? "<cleared>");
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 = "" });
}
@@ -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 });
}
/// <summary>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.</summary>
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);
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/instances</c> — active
/// Photon instances for one specific room. Subset of the global
/// <c>/instances</c> 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);
/// <summary>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.</summary>
[HttpPost("players/{id:long}/level")]
public async Task<ActionResult> 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 });
}
/// <summary>Insert one <see cref="CheerEntity"/> 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")]
@@ -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<bool> 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();
}
/// <summary>
/// Apply the watch's <c>?cropSquare=1</c> + <c>?width=N</c> transforms
/// per-request on the way out and sign the *resulting* bytes — the
@@ -586,9 +586,11 @@ public class GoToController(
/// <summary>POST <c>/goto/player/{playerId}</c> — join the room
/// the target player is currently in.
///
/// For PUBLIC rooms: mirror the target's room name and rebuild a
/// fresh RoomInstance (the caller may end up in a different
/// matchmade instance, which is fine for public rooms).
/// For 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,
};
/// <summary>Record the just-built response against the bearer-token
/// player so the next heartbeat reflects the room they joined.
/// Anonymous /goto calls (no JWT) skip recording — the heartbeat is
@@ -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(
}
}
/// <summary>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.</summary>
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));
}
/// <summary>Push a notification to every currently-connected
/// player. Used for global announcements and admin broadcasts.</summary>
public async Task BroadcastAsync(PushNotificationId id, object? msg = null)
+19 -6
View File
@@ -30,10 +30,28 @@ public class RoomDataBlobService
/// startup beats rebuilding on every download request.
/// </summary>
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;
}
}
@@ -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</button>
</div>
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Set level</h3>
<p className="text-xs text-ink-400 mb-3">Moves XP to the floor for that level.</p>
<div className="flex gap-2 mb-2">
<input
type="number"
min={1}
max={100}
value={targetLevel}
onChange={e => setTargetLevel(parseInt(e.target.value || '1'))}
className="input"
/>
</div>
<button
onClick={() => run('Level', () => api(`/players/${data.id}/level`, { method: 'POST', body: { Level: targetLevel } }))}
disabled={!Number.isFinite(targetLevel) || targetLevel < 1 || targetLevel === data.level}
className="btn-primary text-xs"
>Set level</button>
</div>
<div className="card !p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Unlock cheer badges</h3>
<p className="text-xs text-ink-400 mb-3">