mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Fix live room and account updates
This commit is contained in:
@@ -128,6 +128,8 @@ public class AccountApiController(
|
|||||||
var name = (displayName ?? value ?? string.Empty).Trim();
|
var name = (displayName ?? value ?? string.Empty).Trim();
|
||||||
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
|
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
|
||||||
await playerService.UpdateDisplayNameAsync(p.Id, name);
|
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 });
|
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" });
|
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
|
||||||
p.Username = newName;
|
p.Username = newName;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await notifications.AccountChanged(p);
|
||||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,44 +225,10 @@ public class AccountApiController(
|
|||||||
"[api-account] profile image set: player={PlayerId} -> {Name}",
|
"[api-account] profile image set: player={PlayerId} -> {Name}",
|
||||||
player.Id,
|
player.Id,
|
||||||
player.ProfileImageName ?? "<cleared>");
|
player.ProfileImageName ?? "<cleared>");
|
||||||
await NotifyAccountImageChangedAsync(player);
|
await notifications.AccountChanged(player);
|
||||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
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/namegen")]
|
||||||
[HttpGet("api/account/v1/namegeneration")]
|
[HttpGet("api/account/v1/namegeneration")]
|
||||||
public IActionResult NameGen() => Ok(new
|
public IActionResult NameGen() => Ok(new
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public class PlayersController(PlayerService playerService, NotificationService
|
|||||||
if (!updated) return NotFound();
|
if (!updated) return NotFound();
|
||||||
|
|
||||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
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!));
|
return Ok(PlayerService.ToProfile(player!));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ public class PlayersController(PlayerService playerService, NotificationService
|
|||||||
await playerService.UpdateBioAsync(id, req.Bio);
|
await playerService.UpdateBioAsync(id, req.Bio);
|
||||||
|
|
||||||
var player = await playerService.GetByIdAsync(id);
|
var player = await playerService.GetByIdAsync(id);
|
||||||
|
await notificationService.AccountChanged(player!);
|
||||||
return Ok(PlayerService.ToProfile(player!));
|
return Ok(PlayerService.ToProfile(player!));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,19 +393,10 @@ public class AccountsController(
|
|||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"[accounts] profile image set: player={PlayerId} → {Name}",
|
"[accounts] profile image set: player={PlayerId} → {Name}",
|
||||||
player.Id, player.ProfileImageName ?? "<cleared>");
|
player.Id, player.ProfileImageName ?? "<cleared>");
|
||||||
await NotifyAccountImageChangedAsync(player);
|
await notifications.AccountChanged(player);
|
||||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
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)
|
private static int AgeYears(DateTime birthday)
|
||||||
{
|
{
|
||||||
var today = DateTime.UtcNow.Date;
|
var today = DateTime.UtcNow.Date;
|
||||||
@@ -440,6 +431,7 @@ public class AccountsController(
|
|||||||
if (player is null) return Unauthorized();
|
if (player is null) return Unauthorized();
|
||||||
await playerService.UpdateUsernameAsync(player.Id, username.Trim());
|
await playerService.UpdateUsernameAsync(player.Id, username.Trim());
|
||||||
var updated = await playerService.GetByIdAsync(player.Id);
|
var updated = await playerService.GetByIdAsync(player.Id);
|
||||||
|
if (updated is not null) await notifications.AccountChanged(updated);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
@@ -468,6 +460,8 @@ public class AccountsController(
|
|||||||
var player = await GetCurrentPlayerAsync();
|
var player = await GetCurrentPlayerAsync();
|
||||||
if (player is null) return Unauthorized();
|
if (player is null) return Unauthorized();
|
||||||
await playerService.UpdateDisplayNameAsync(player.Id, displayName.Trim());
|
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 = "" });
|
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 LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyRoomRolesChangedAsync(id, body.PlayerId, "AdminRoleGrant");
|
||||||
return Ok(new { ok = true });
|
return Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,6 +1061,7 @@ public class AdminController(
|
|||||||
db.RoomRoles.Remove(row);
|
db.RoomRoles.Remove(row);
|
||||||
await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}");
|
await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyRoomRolesChangedAsync(id, playerId, "AdminRoleRevoke");
|
||||||
return Ok(new { ok = true, deleted = 1 });
|
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 LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyRoomRolesChangedAsync(id, previousOwner, "AdminOwnerTransfer");
|
||||||
|
await NotifyRoomRolesChangedAsync(id, body.NewCreatorPlayerId, "AdminOwnerTransfer");
|
||||||
return Ok(new { ok = true });
|
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
|
/// <summary>GET <c>api/admin/v1/rooms/{id}/instances</c> — active
|
||||||
/// Photon instances for one specific room. Subset of the global
|
/// Photon instances for one specific room. Subset of the global
|
||||||
/// <c>/instances</c> endpoint pre-filtered to one room; the admin
|
/// <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 });
|
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
|
/// <summary>Insert one <see cref="CheerEntity"/> row per cheer Type
|
||||||
/// (0=General, 1=Helpful, 2=GreatHost, 3=Sportsman, 4=Creative,
|
/// (0=General, 1=Helpful, 2=GreatHost, 3=Sportsman, 4=Creative,
|
||||||
/// 5=Credit) so the player has at least one received cheer in every
|
/// 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();
|
p.DisplayName = body.DisplayName.Trim();
|
||||||
await LogAsync("set_display_name", "player", id, p.DisplayName);
|
await LogAsync("set_display_name", "player", id, p.DisplayName);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await notifications.AccountChanged(p);
|
||||||
return Ok(new { p.Id, p.DisplayName });
|
return Ok(new { p.Id, p.DisplayName });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1979,9 +2043,16 @@ public class AdminController(
|
|||||||
p.Username = name;
|
p.Username = name;
|
||||||
await LogAsync("set_username", "player", id, $"{previous} -> {name}");
|
await LogAsync("set_username", "player", id, $"{previous} -> {name}");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await notifications.AccountChanged(p);
|
||||||
return Ok(new { p.Id, p.Username });
|
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
|
private sealed class InventoryEntry
|
||||||
{
|
{
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
|
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
|
||||||
|
|||||||
@@ -351,12 +351,31 @@ public class CdnController(
|
|||||||
}
|
}
|
||||||
if (s3Bytes is { Length: > 0 })
|
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}",
|
logger.LogInformation("[cdn] s3 hit host={Host} file={File} bytes={Bytes}",
|
||||||
Request.Host.Host, fileName, s3Bytes.Length);
|
Request.Host.Host, fileName, s3Bytes.Length);
|
||||||
// Allow CF edge to cache for an hour. RoomDataBlobs are
|
// Allow CF edge to cache for an hour. RoomDataBlobs are
|
||||||
// content-addressed by hash so the same BlobName never
|
// content-addressed by hash so the same BlobName never
|
||||||
// serves different bytes — safe to cache aggressively.
|
// 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);
|
signatures.AddContentSignature(Response, s3Bytes);
|
||||||
return new FileContentResult(s3Bytes, MimeFromName(fileName));
|
return new FileContentResult(s3Bytes, MimeFromName(fileName));
|
||||||
}
|
}
|
||||||
@@ -381,6 +400,22 @@ public class CdnController(
|
|||||||
return new FileContentResult(fallbackBlob, "application/octet-stream");
|
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>
|
/// <summary>
|
||||||
/// Apply the watch's <c>?cropSquare=1</c> + <c>?width=N</c> transforms
|
/// 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
|
/// 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
|
/// <summary>POST <c>/goto/player/{playerId}</c> — join the room
|
||||||
/// the target player is currently in.
|
/// the target player is currently in.
|
||||||
///
|
///
|
||||||
/// For PUBLIC rooms: mirror the target's room name and rebuild a
|
/// For PUBLIC rooms: mirror the target's live RoomInstance. The
|
||||||
/// fresh RoomInstance (the caller may end up in a different
|
/// 2020.12 client reconciles joined players against the exact
|
||||||
/// matchmade instance, which is fine for public rooms).
|
/// 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.
|
/// For PRIVATE rooms: this route must NOT bypass the invite list.
|
||||||
/// Earlier we mirrored the target's RoomInstance verbatim under
|
/// 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
|
// Allowed — mirror the target's RoomInstance so we land
|
||||||
// in the same Photon match.
|
// in the same Photon match.
|
||||||
resp = new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = theirRoom };
|
resp = new MatchmakingResponseDto
|
||||||
|
{
|
||||||
|
ErrorCode = 0,
|
||||||
|
RoomInstance = CloneRoomInstance(theirRoom),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var roomName = await db.Rooms
|
// Public follow-joins still need to land in the target's
|
||||||
.Where(r => r.Id == theirRoom.RoomId)
|
// specific live instance, not just the same room name. This
|
||||||
.Select(r => r.Name)
|
// preserves sub-room and Photon shard identity for PUN's
|
||||||
.FirstOrDefaultAsync();
|
// player-enter reconciliation.
|
||||||
resp = await BuildResponseAsync(roomName ?? "DormRoom");
|
resp = new MatchmakingResponseDto
|
||||||
|
{
|
||||||
|
ErrorCode = 0,
|
||||||
|
RoomInstance = CloneRoomInstance(theirRoom),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
await RecordResponseAsync(resp);
|
await RecordResponseAsync(resp);
|
||||||
return Ok(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
|
/// <summary>Record the just-built response against the bearer-token
|
||||||
/// player so the next heartbeat reflects the room they joined.
|
/// player so the next heartbeat reflects the room they joined.
|
||||||
/// Anonymous /goto calls (no JWT) skip recording — the heartbeat is
|
/// Anonymous /goto calls (no JWT) skip recording — the heartbeat is
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using DorkNet.Models.Notification;
|
using DorkNet.Models.Notification;
|
||||||
using DorkNet.Server.Controllers.Match;
|
using DorkNet.Server.Controllers.Match;
|
||||||
|
using DorkNet.Models.Auth;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
using DorkNet.Server.Hubs;
|
using DorkNet.Server.Hubs;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using System.Text.Json;
|
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
|
/// <summary>Push a notification to every currently-connected
|
||||||
/// player. Used for global announcements and admin broadcasts.</summary>
|
/// player. Used for global announcements and admin broadcasts.</summary>
|
||||||
public async Task BroadcastAsync(PushNotificationId id, object? msg = null)
|
public async Task BroadcastAsync(PushNotificationId id, object? msg = null)
|
||||||
|
|||||||
@@ -30,10 +30,28 @@ public class RoomDataBlobService
|
|||||||
/// startup beats rebuilding on every download request.
|
/// startup beats rebuilding on every download request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly byte[] _allPermsBlob = BuildAllPermsBlob();
|
private readonly byte[] _allPermsBlob = BuildAllPermsBlob();
|
||||||
|
private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData();
|
||||||
|
|
||||||
public byte[] GetDefaultBlob() => _allPermsBlob;
|
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()
|
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
|
// OverridableBoolData(overrides=true, inner_value=true) — used for
|
||||||
// every Can* field on the role.
|
// every Can* field on the role.
|
||||||
@@ -101,11 +119,6 @@ public class RoomDataBlobService
|
|||||||
var collection = new RoomRoleCollectionData();
|
var collection = new RoomRoleCollectionData();
|
||||||
collection.RoomRoles.Add(role);
|
collection.RoomRoles.Add(role);
|
||||||
|
|
||||||
var msg = new PersistedRoomData
|
return collection;
|
||||||
{
|
|
||||||
RoomRoleData = collection,
|
|
||||||
};
|
|
||||||
|
|
||||||
return msg.ToByteArray();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v
|
|||||||
const [currType, setCurrType] = useState(2);
|
const [currType, setCurrType] = useState(2);
|
||||||
const [currAmount, setCurrAmount] = useState(1000);
|
const [currAmount, setCurrAmount] = useState(1000);
|
||||||
const [xp, setXp] = useState(1000);
|
const [xp, setXp] = useState(1000);
|
||||||
|
const [targetLevel, setTargetLevel] = useState(data.level);
|
||||||
const [itemId, setItemId] = useState('');
|
const [itemId, setItemId] = useState('');
|
||||||
const [itemQty, setItemQty] = useState(1);
|
const [itemQty, setItemQty] = useState(1);
|
||||||
|
|
||||||
@@ -424,6 +425,26 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v
|
|||||||
>Grant XP</button>
|
>Grant XP</button>
|
||||||
</div>
|
</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">
|
<div className="card !p-4 md:col-span-2">
|
||||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Unlock cheer badges</h3>
|
<h3 className="text-sm font-semibold text-ink-50 mb-1">Unlock cheer badges</h3>
|
||||||
<p className="text-xs text-ink-400 mb-3">
|
<p className="text-xs text-ink-400 mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user