using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json; using RadicadeServer.API.Storefront; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Accounts; using RadicadeServer.Utils; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Store; namespace RadicadeServer.API.Avatars.V2; [Route("api/avatar/v2")] [Route("econ/api/avatar/v2")] public class AvatarV2(RecDBContext db) : Controller { [HttpGet(""), Authorize] public async Task GetLocalAvatar() { var userId = User.GetUserId(); if (userId == null) return null; var avatar = db.Avatars.FirstOrDefault(x => x.AccountId == userId); if (avatar != null) { if (NormalizeSavedAvatar(avatar)) { await db.SaveChangesAsync(); } return avatar.DeserializeAvatar(); } avatar = new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar { AccountId = userId.Value, Avatar = DBConfig.DefaultAvatar, Outfits = "[]" }; await db.Avatars.AddAsync(avatar); await db.SaveChangesAsync(); return avatar.DeserializeAvatar(); } [HttpGet("{accountId:long}")] public async Task GetAvatarForAccount(long accountId) { var saved = await db.Avatars.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == accountId); var avatar = saved?.DeserializeAvatar(); if (avatar != null) return avatar; return DBConfig.DefaultAvatar.FromJson() ?? new Avatar(); } [HttpPost("set"), Authorize] public async Task SetLocalAvatar([FromBody] Avatar newAvatar) { var userId = User.GetUserId(); if (userId == null) return null; var avatar = await db.Avatars.FirstOrDefaultAsync(x => x.AccountId == userId); if (avatar == null) { avatar = new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar { AccountId = userId.Value, Avatar = DBConfig.DefaultAvatar, Outfits = "[]" }; await db.Avatars.AddAsync(avatar); } avatar.Avatar = newAvatar.ToJson(); await db.SaveChangesAsync(); return newAvatar; } [HttpGet("gifts"), Authorize] public async Task GetGifts() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value); return Ok(gifts.Select(g => g.ForClient()).ToList()); } [HttpPost("gifts/claim"), Authorize] public async Task ClaimGifts() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var requestedIds = await ReadGiftIdsFromBodyAsync(); var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value); var toClaim = requestedIds.Count > 0 ? gifts.Where(g => requestedIds.Contains(g.GiftDropId)).ToList() : gifts; if (toClaim.Count == 0) { return GiftClaimResponse(new List(), await GetPlayerLevelAsync(userId.Value)); } var claimed = new List(); var levelUpGifts = new List(); var balanceGrants = new List(); var strategy = db.Database.CreateExecutionStrategy(); await strategy.ExecuteAsync(async () => { await using var tx = await db.Database.BeginTransactionAsync(); try { foreach (var gift in toClaim) { var grant = await GrantGift(userId.Value, gift); if (grant != null) balanceGrants.Add(grant); await StorefrontShared.RemovePendingGiftAsync(db, userId.Value, gift.GiftDropId); claimed.Add(gift); } levelUpGifts.AddRange(await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, userId.Value, recomputeFromXp: true, notify: false)); await tx.CommitAsync(); await AnnounceBalancesAsync(userId.Value, balanceGrants, StorefrontBalanceAddTypes.FromGiftBox); } catch { await tx.RollbackAsync(); throw; } }); foreach (var gift in levelUpGifts) await StorefrontShared.SendGiftPackageNotificationAsync(userId.Value, gift); return GiftClaimResponse(claimed, await GetPlayerLevelAsync(userId.Value)); } [HttpPost("gifts/generate"), Authorize] public async Task GenerateGift() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var fields = await ReadGiftGenerateFieldsAsync(); if (!TryReadGiftContext(fields, "MFPELEJLJLD", "GiftContext", out var giftContext) || !TryReadOptionalGiftContext(fields, "CIEHFJEIOJJ", "AlternateGiftContext", out var alternateContext)) return BadRequest(new { error = "invalid_gift_context" }); fields.TryGetValue("Message", out var message); if (message == null && fields.TryGetValue("DPAJDIEBNKM", out var opaqueMessage)) message = opaqueMessage; var xp = TryFindField(fields, out var rawXp, "Xp", "XP") && int.TryParse(rawXp, out var parsedXp) ? parsedXp : 0; var context = (GiftContext)giftContext; if (context is GiftContext.All_Weekly_Challenge_Complete or GiftContext.Weekly_Challenge_Complete) { var mapId = WeeklyChallenges.CreateMap(DateTime.UtcNow).ChallengeMapId; var state = await db.WeeklyChallenges.AsNoTracking().SingleOrDefaultAsync(x => x.PlayerId == userId.Value && x.ChallengeMapId == mapId); var pendingWeekly = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value); var weeklyGift = pendingWeekly.FirstOrDefault(x => x.GiftDropId == state?.RewardGiftDropId); return weeklyGift == null ? Conflict(new { error = "weekly_reward_not_pending" }) : Ok(weeklyGift.ForClient()); } void Trace(string line) => RewardTrace.Log(userId.Value, line); var room = RroRewardPools.RoomForContext(context); var paysReward = room != null && RroRewardPools.IsRewardActivity(room); RewardTrace.Log(userId.Value, $"stage=Generate claimed={RewardTrace.Context(context)} " + $"room={(room ?? "")} isActivityReward={paysReward} xp={xp} message=\"{message}\""); var gift = await GameRewardRedemption.TakeOutstandingAsync(db, userId.Value, context, message, Trace); var source = gift == null ? null : "outstanding reward"; if (gift == null) { gift = await GameRewardRedemption.TakePendingBoxAsync(db, userId.Value, context, Trace); if (gift != null) source = "box the selection route already handed over"; } if (gift == null) { gift = await GameRewardRedemption.TakeRecentlyHandedOverAsync( db, userId.Value, context, TimeSpan.FromMinutes(10), Trace); if (gift != null) source = "box the server already handed over for this reward"; } if (gift == null && paysReward) { gift = await GameRewardRedemption.BuildRoomRewardAsync(db, userId.Value, context, message, Trace); if (gift != null) source = "the activity's own reward, selected by the server"; else { var sets = string.Join(", ", RroRewardPools.SetsFor(context) .Select(x => string.IsNullOrEmpty(x.Colour) ? x.Family : $"{x.Family} {x.Colour}")); RewardTrace.Error(userId.Value, "Unable to resolve the reward for this context. " + $"Activity={room} Context={RewardTrace.Context(context)} ExpectedPool={sets} " + $"EligibleItems={RroRewardPools.Resolve(context).Count} " + "Reason=the activity's own pool, its lower ranks and the non-exclusive pool all " + "resolved to nothing."); } } if (gift == null) { RewardTrace.Log(userId.Value, $"stage=Fallback claimed={RewardTrace.Context(context)} " + "is not an activity this server pays a reward for; generating the ordinary box."); gift = BuildGeneratedGift(userId.Value, context, message, xp); source = "generated box (no activity reward)"; } await StorefrontShared.PersistGiftBoxAsync(db, userId.Value, gift, notify: false); RewardTrace.Log(userId.Value, $"stage=Grant source={source} box={RewardTrace.Box(gift)}"); return Ok(gift.ForClient()); } private async Task> ReadGiftGenerateFieldsAsync() { var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var field in form) fields[field.Key] = field.Value.FirstOrDefault() ?? string.Empty; return fields; } Request.EnableBuffering(); using var reader = new StreamReader(Request.Body, leaveOpen: true); var raw = (await reader.ReadToEndAsync()).Trim(); Request.Body.Position = 0; if (raw.Length == 0) return fields; if (raw.StartsWith('{')) { try { using var document = JsonDocument.Parse(raw); foreach (var property in document.RootElement.EnumerateObject()) fields[property.Name] = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : property.Value.ToString(); } catch (JsonException) { } } else { foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries)) { var parts = pair.Split('=', 2); fields[Uri.UnescapeDataString(parts[0].Replace('+', ' '))] = parts.Length > 1 ? Uri.UnescapeDataString(parts[1].Replace('+', ' ')) : string.Empty; } } return fields; } private static bool TryReadGiftContext(IReadOnlyDictionary fields, string opaqueName, string friendlyName, out int value) { value = 0; if (!TryFindField(fields, out var raw, opaqueName, friendlyName)) return false; if (!int.TryParse(raw, out value)) return false; return Enum.IsDefined(typeof(GiftContext), value) && (GiftContext)value != GiftContext.None; } private static bool TryReadOptionalGiftContext(IReadOnlyDictionary fields, string opaqueName, string friendlyName, out int? value) { value = null; if (!TryFindField(fields, out var raw, opaqueName, friendlyName) || string.IsNullOrWhiteSpace(raw)) return true; if (!int.TryParse(raw, out var parsed) || !Enum.IsDefined(typeof(GiftContext), parsed)) return false; value = parsed; return true; } private static bool TryFindField(IReadOnlyDictionary fields, out string raw, params string[] names) { foreach (var name in names) { if (fields.TryGetValue(name, out var value)) { raw = value; return true; } } raw = string.Empty; return false; } private static Gift BuildGeneratedGift(long playerId, GiftContext context, string? message, int xp) { int[] tokenAmounts = [10, 25, 50, 100, 250, 500]; return new Gift { GiftDropId = StorefrontShared.NewGiftDropId(), FromPlayerId = playerId, Tooltip = message ?? string.Empty, CurrencyType = CurrencyType.RecCenterTokens, Currency = tokenAmounts[Random.Shared.Next(tokenAmounts.Length)], Xp = xp, Context = context, BalanceType = 0, Rarity = Rarity.Rare }; } [HttpPost("gifts/consume/{giftDropId:long}"), Authorize] public async Task ConsumeGift(long giftDropId) => await ConsumePlayerGift(giftDropId, await ReadGiftIdsFromBodyAsync()); [HttpPost("gifts/consume"), Authorize] public async Task ConsumeGiftBody() => await ConsumePlayerGift(null, await ReadGiftIdsFromBodyAsync()); private async Task> ReadGiftIdsFromBodyAsync() { var ids = new List(); try { Request.EnableBuffering(); using var reader = new StreamReader(Request.Body, leaveOpen: true); var raw = (await reader.ReadToEndAsync()).Trim(); Request.Body.Position = 0; if (raw.Length == 0) return ids; if (raw.StartsWith('{')) { using var doc = JsonDocument.Parse(raw); foreach (var prop in doc.RootElement.EnumerateObject()) { if (!string.Equals(prop.Name, "Id", StringComparison.OrdinalIgnoreCase) && !string.Equals(prop.Name, "giftDropId", StringComparison.OrdinalIgnoreCase)) continue; if (TryReadGiftId(prop.Value, out var v)) ids.Add(v); } foreach (var prop in doc.RootElement.EnumerateObject()) { if (!string.Equals(prop.Name, "giftDropIds", StringComparison.OrdinalIgnoreCase) || prop.Value.ValueKind != JsonValueKind.Array) continue; foreach (var el in prop.Value.EnumerateArray()) if (TryReadGiftId(el, out var v)) ids.Add(v); } } else { foreach (var pair in raw.Split('&')) { var kv = pair.Split('=', 2); var key = Uri.UnescapeDataString(kv[0]).Trim(); if (!string.Equals(key, "Id", StringComparison.OrdinalIgnoreCase) && !string.Equals(key, "giftDropId", StringComparison.OrdinalIgnoreCase)) continue; var val = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]).Trim() : ""; if (long.TryParse(val, out var v)) ids.Add(v); } if (ids.Count == 0 && long.TryParse(raw, out var bare)) ids.Add(bare); } } catch (Exception) { } return ids.Distinct().ToList(); } private static bool TryReadGiftId(JsonElement el, out long v) { if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out v)) return true; if (el.ValueKind == JsonValueKind.String && long.TryParse(el.GetString(), out v)) return true; v = 0; return false; } private async Task ConsumePlayerGift(long? giftDropId, List bodyIds) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var requestedIds = new List(); if (giftDropId is > 0) requestedIds.Add(giftDropId.Value); if (bodyIds is { Count: > 0 }) requestedIds.AddRange(bodyIds); var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value); var toConsume = requestedIds.Count > 0 ? gifts.Where(g => requestedIds.Contains(g.GiftDropId)).ToList() : gifts; if (toConsume.Count == 0) { var delivered = requestedIds.Count > 0 ? await HandedOverBoxIdsAsync(userId.Value, requestedIds) : new List(); if (delivered.Count > 0) { return GiftClaimResponse(new List(), await GetPlayerLevelAsync(userId.Value)); } return NotFound(new { error = "unable_to_consume_missing_or_used_gift", giftDropId }); } var consumed = new List(); var levelUpGifts = new List(); var balanceGrants = new List(); var strategy = db.Database.CreateExecutionStrategy(); await strategy.ExecuteAsync(async () => { await using var tx = await db.Database.BeginTransactionAsync(); try { foreach (var gift in toConsume) { var grant = await GrantGift(userId.Value, gift); if (grant != null) balanceGrants.Add(grant); await StorefrontShared.RemovePendingGiftAsync(db, userId.Value, gift.GiftDropId); consumed.Add(gift); } levelUpGifts.AddRange(await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, userId.Value, recomputeFromXp: true, notify: false)); await tx.CommitAsync(); await AnnounceBalancesAsync(userId.Value, balanceGrants, StorefrontBalanceAddTypes.FromGiftBox); } catch { await tx.RollbackAsync(); throw; } }); foreach (var gift in levelUpGifts) await StorefrontShared.SendGiftPackageNotificationAsync(userId.Value, gift); if (consumed.Count > 0) await ProgressionAwardService.AwardXpAsync(db, userId.Value, consumed.Count * ProgressionAwardService.XpPerGiftBoxOpened, "gift box"); return GiftClaimResponse(consumed, await GetPlayerLevelAsync(userId.Value)); } private async Task GetPlayerLevelAsync(long playerId) { var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId); return progression?.Level ?? 1; } private async Task> HandedOverBoxIdsAsync(long playerId, List requestedIds) { var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId); var rewards = data?.GetProgressionJson()?.PendingGameRewards; if (rewards == null) return new List(); return rewards .Where(r => r.ConsumedGiftDropId != 0 && requestedIds.Contains(r.ConsumedGiftDropId)) .Select(r => r.ConsumedGiftDropId) .Distinct() .ToList(); } private IActionResult GiftClaimResponse(List claimed, int unlockedLevel) { var body = new Dictionary { ["consumed"] = claimed.Select(g => g.ForClient()).ToList(), ["UnlockedLevel"] = unlockedLevel }; var first = claimed.FirstOrDefault(); if (first != null) { foreach (var kv in first.ForClient().ToJson().FromJson>() ?? new Dictionary()) body[kv.Key] = kv.Value; } return Ok(body); } private async Task GrantGift(long playerId, Gift gift) { return await StorefrontShared.GrantGiftAsync(db, playerId, gift); } private static async Task AnnounceBalancesAsync(long playerId, List grants, StorefrontBalanceAddTypes addType) { foreach (var grant in grants) await StorefrontShared.SendBalanceUpdateAsync(playerId, grant.CurrencyType, grant.Balance, grant.Granted, addType); } private static bool NormalizeSavedAvatar(RadicadeServer.RecNet.Accounts.Local.PlayerAvatar avatar) { if (string.IsNullOrWhiteSpace(avatar.Avatar)) { avatar.Avatar = DBConfig.DefaultAvatar; return true; } var original = avatar.Avatar; avatar.Avatar = RepairKnownUnsafeOutfitSelections(avatar.Avatar); return avatar.Avatar != original; } private static string RepairKnownUnsafeOutfitSelections(string avatarJson) { const string body = "d0a9262f-5504-46a7-bb10-7507503db58e"; const string accessory = "95e4cc30-cb68-473d-a395-feadf5b51512"; const string repairedOutfitSelection = body + "," + accessory + ",0440f08f-ef1d-49d8-942b-523056e8bb45,,1"; string[] shortOutfitSelections = [ body + ",,,,1", body + "," + accessory + ",,,1" ]; foreach (var shortOutfitSelection in shortOutfitSelections) { avatarJson = avatarJson.Replace( shortOutfitSelection, repairedOutfitSelection, StringComparison.OrdinalIgnoreCase); } return avatarJson; } } public class ClaimGiftsRequest { public List? GiftDropIds { get; set; } public long? GiftDropId { get; set; } } public class GenerateGiftRequest { public long? RecipientPlayerId { get; set; } public long? RecipientId { get; set; } public int? PurchasableItemId { get; set; } public int? ItemId { get; set; } public long? GiftDropId { get; set; } public StorefrontTypes? Storefront { get; set; } public CurrencyType? Currency { get; set; } public long? Price { get; set; } public string? Message { get; set; } }