mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -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();
|
||||
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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user