using System.Collections.Concurrent; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Storefront; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Storefront; using RadicadeServer.Utils; namespace RadicadeServer.API.GameRewards.V1 { public class GameRewardRequestDTO { public RewardType RewardType { get; set; } public string Message { get; set; } = string.Empty; public GiftContext? GiftContext { get; set; } } public class GameRewardSelectDTO { public long RewardSelectionId { get; set; } public long GiftDropId { get; set; } } [Route("api/gamerewards/v1")] [Route("econ/api/gamerewards/v1")] public class GameRewardsAPI(RecDBContext db) : Controller { [HttpGet("pending")] public async Task> GetPendingGameRewards() { var userId = User.GetUserId(); if (userId == null) return new List(); var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId.Value); var progression = data?.GetProgressionJson(); var pending = progression?.PendingGameRewards; if (pending == null) return new List(); var changed = false; var forgotten = pending.RemoveAll(r => r.Consumed && r.ConsumedAt is { } at && at < DateTime.UtcNow.AddDays(-1)); changed |= forgotten > 0; var outstanding = pending.Where(r => !r.Consumed).ToList(); foreach (var reward in outstanding) changed |= ClampRewardIds(reward); if (changed && data != null) { data.Progress = progression!.ToJson(); await db.SaveChangesAsync(); } return outstanding; } private static bool ClampRewardIds(GameReward reward) { var changed = false; void Clamp(Gift? gift) { if (gift == null || (gift.GiftDropId >= int.MinValue && gift.GiftDropId <= int.MaxValue)) return; gift.GiftDropId = StorefrontShared.NewGiftDropId(); changed = true; } if (reward.RewardSelectionId > int.MaxValue || reward.RewardSelectionId < int.MinValue) { _ = reward.RewardSelectionId; } Clamp(reward.GiftDrop1); Clamp(reward.GiftDrop2); Clamp(reward.GiftDrop3); return changed; } [HttpPost("request"), Authorize] public async Task> RequestReward() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var dto = await ReadRewardBodyAsync(); if (dto == null) return BadRequest(); if (dto.RewardType == RewardType.WeeklyCompleteButHaveSkin) return Ok(new OkResponse { Success = true }); Trace(userId.Value, $"claimed rewardType={dto.RewardType} ({(int)dto.RewardType}) giftContext={dto.GiftContext?.ToString() ?? "none"} body={_rawBody}"); var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId.Value); if (data == null) { data = new ProgressionData { PlayerId = userId.Value }; db.Progression.Add(data); } var json = data.GetProgressionJson() ?? new ProgressionJson(); json.PendingGameRewards ??= new List(); var ledger = RewardDay.For(json, DateTime.UtcNow); var completion = IsCompletion(dto.RewardType); GameReward? reward = null; if (completion) { var rewardType = RewardDay.CompletionType(ledger, dto.RewardType); var paid = rewardType == RewardType.FirstActivityOfDay ? RewardDay.TryTakeFirstActivity(ledger) : RewardDay.TryTakeActivityBox(ledger); if (paid) { dto.Message = CompletionMessage(rewardType, dto.Message); reward = await BuildRewardAsync(userId.Value, dto, rewardType); } } else if (dto.RewardType == RewardType.DailyChallengesComplete) { if (RewardDay.TryTakeDailyChallengeBox(ledger)) { dto.Message = string.IsNullOrWhiteSpace(dto.Message) ? "Daily Challenge Reward" : dto.Message; reward = await BuildDailyChallengeRewardAsync(userId.Value, dto); } } else { reward = BuildReward(dto.RewardType, dto.Message, BonusContext(dto.RewardType) ?? dto.GiftContext ?? GiftContext.GameRewards_Tokens); } if (reward != null) json.PendingGameRewards.Add(reward); data.Progress = json.ToJson(); await db.SaveChangesAsync(); if (completion) { await ProgressionAwardService.AwardXpAsync(db, userId.Value, ProgressionAwardService.XpPerGameCompleted, "game completed"); } if (reward == null) return Ok(new OkResponse { Success = true }); if (RroRewardPools.IsQuestContext(reward.GiftContext)) { await DeliverQuestRewardAsync(userId.Value, reward); data.Progress = json.ToJson(); await db.SaveChangesAsync(); return Ok(new OkResponse { Success = true }); } await PushRewardSelectionAsync(userId.Value, reward); return Ok(new OkResponse { Success = true }); } private async Task DeliverQuestRewardAsync(long playerId, GameReward reward) { var box = reward.GiftDrop1; if (box == null) return; if (box.GiftDropId == 0) box.GiftDropId = StorefrontShared.NewGiftDropId(); var already = await GameRewardRedemption.TakeRecentlyHandedOverAsync( db, playerId, reward.GiftContext, TimeSpan.FromMinutes(10)); if (already == null) await StorefrontShared.PersistGiftBoxAsync(db, playerId, box, notify: true); reward.Consumed = true; reward.ConsumedGiftDropId = already?.GiftDropId ?? box.GiftDropId; reward.ConsumedAt = DateTime.UtcNow; RewardTrace.Log(playerId, $"stage=DeliveredAsQuestBox context={RewardTrace.Context(reward.GiftContext)} " + $"selectionId={reward.RewardSelectionId} alreadyHandedOver={already != null} box={RewardTrace.Box(box)} " + "a quest's reward is the box its run generated, not a selection."); } private static string CompletionMessage(RewardType rewardType, string? claimed) => rewardType switch { RewardType.FirstActivityOfDay => "First Game of the Day", RewardType.PostQuestActivity => string.IsNullOrWhiteSpace(claimed) ? "Quest Complete" : claimed, _ => "Game Reward" }; private async Task BuildDailyChallengeRewardAsync(long playerId, GameRewardRequestDTO dto) { var owned = await LevelUpGiftService.CollectOwnedKeysAsync(db, playerId, GiftContext.Daily_Challenge_Complete); var drops = RroRewardPools.BuildDailyChallengeSelection(owned: owned); if (drops.Count == 0) { return BuildReward(dto.RewardType, dto.Message, GiftContext.Daily_Challenge_Complete); } foreach (var drop in drops) drop.GiftDropId = StorefrontShared.NewGiftDropId(); return new GameReward { RewardSelectionId = StorefrontShared.NewGiftDropId(), Message = string.IsNullOrWhiteSpace(dto.Message) ? "Daily Challenge Reward" : dto.Message, GiftContext = GiftContext.Daily_Challenge_Complete, RewardType = dto.RewardType, GiftDrop1 = drops[0], GiftDrop2 = drops.Count > 1 ? drops[1] : drops[0], GiftDrop3 = drops.Count > 2 ? drops[2] : drops[0], CreatedAt = DateTime.UtcNow }; } [HttpPost("select"), Authorize] public async Task> SelectReward() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var dto = await ReadSelectBodyAsync(); if (dto == null || dto.RewardSelectionId <= 0 || dto.GiftDropId <= 0) return BadRequest(); var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId.Value); if (data == null) return NotFound(); var json = data.GetProgressionJson(); var reward = json?.PendingGameRewards?.FirstOrDefault(r => r.RewardSelectionId == dto.RewardSelectionId); if (reward == null) return NotFound(); var drops = new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 } .Where(g => g != null) .ToList(); if (reward.Consumed) { return Ok(new OkResponse { Success = true }); } var gift = drops.FirstOrDefault(g => g.GiftDropId == dto.GiftDropId); if (gift == null) { var available = string.Join(", ", drops.Select(d => d.GiftDropId)); gift = drops.FirstOrDefault(); if (gift == null) return NotFound(); } await StorefrontShared.PersistGiftBoxAsync(db, userId.Value, gift, notify: true); reward.Consumed = true; reward.ConsumedGiftDropId = gift.GiftDropId; reward.ConsumedAt = DateTime.UtcNow; data.Progress = json!.ToJson(); await db.SaveChangesAsync(); return Ok(new OkResponse { Success = true }); } private async Task ReadRewardBodyAsync() { var fields = await ReadBodyFieldsAsync(); if (fields.Count == 0) return null; var dto = new GameRewardRequestDTO(); if (fields.TryGetValue("rewardType", out var rewardTypeRaw)) { if (int.TryParse(rewardTypeRaw, out var rewardTypeInt) && Enum.IsDefined(typeof(RewardType), rewardTypeInt)) dto.RewardType = (RewardType)rewardTypeInt; else if (Enum.TryParse(rewardTypeRaw, true, out var rewardTypeEnum)) dto.RewardType = rewardTypeEnum; } if (fields.TryGetValue("Message", out var message)) dto.Message = message; dto.GiftContext = ReadClaimedContext(fields); return dto; } private static GiftContext? ReadClaimedContext(IReadOnlyDictionary fields) { if (!fields.TryGetValue("giftContext", out var raw) || string.IsNullOrWhiteSpace(raw)) return null; if (int.TryParse(raw, out var numeric) && Enum.IsDefined(typeof(GiftContext), numeric)) return (GiftContext)numeric; return Enum.TryParse(raw.Trim(), true, out var named) ? named : null; } private async Task ReadSelectBodyAsync() { var fields = await ReadBodyFieldsAsync(); if (fields.Count == 0) return null; var dto = new GameRewardSelectDTO(); if (fields.TryGetValue("rewardSelectionId", out var selectionRaw) && long.TryParse(selectionRaw, out var selectionId)) dto.RewardSelectionId = selectionId; if (fields.TryGetValue("giftDropId", out var dropRaw) && long.TryParse(dropRaw, out var dropId)) dto.GiftDropId = dropId; return dto; } private async Task> ReadBodyFieldsAsync() { var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); try { Request.EnableBuffering(); using var reader = new StreamReader(Request.Body, leaveOpen: true); var raw = (await reader.ReadToEndAsync()).Trim(); Request.Body.Position = 0; _rawBody = raw; if (raw.Length == 0) return fields; if (raw.StartsWith('{')) { using var doc = System.Text.Json.JsonDocument.Parse(raw); if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object) { foreach (var prop in doc.RootElement.EnumerateObject()) { fields[prop.Name] = prop.Value.ValueKind == System.Text.Json.JsonValueKind.String ? prop.Value.GetString() ?? "" : prop.Value.ToString(); } } } else { foreach (var pair in raw.Split('&')) { var kv = pair.Split('=', 2); if (kv.Length < 2) continue; fields[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]); } } } catch (Exception) { } return fields; } private async Task BuildRewardAsync(long playerId, GameRewardRequestDTO dto, RewardType rewardType) { var lastRoom = await MatchmakingUtils.GetLastVisitedRoomNameAsync(db, playerId); var roomName = lastRoom; var claimedContext = dto.GiftContext; if (claimedContext.HasValue && RroRewardPools.RoomForContext(claimedContext.Value) is { } claimedRoom && !RroRewardPools.ContextsForRoom(roomName).Values.Contains(claimedContext.Value)) { roomName = claimedRoom; } if (!RroRewardPools.IsRewardActivity(roomName) && await MatchmakingUtils.GetLastRewardActivityRoomNameAsync(db, playerId, ActivityVisitWindow) is { } activityRoom) { Trace(playerId, $"lastRoom={lastRoom} is not an activity this server pays; {activityRoom} is the newest activity they were placed into, so that is the activity this completion is answered from."); roomName = activityRoom; } var paidByRoom = RroRewardPools.ContextsForRoom(roomName); var paidList = string.Join(", ", paidByRoom.Select(x => x.Key + "=" + (int)x.Value)); Trace(playerId, "rewardType=" + rewardType + " claimedContext=" + dto.GiftContext + " lastRoom=" + lastRoom + " activity=" + roomName + " isRewardActivity=" + RroRewardPools.IsRewardActivity(roomName) + " roomPays=[" + paidList + "]"); if (!RroRewardPools.IsRewardActivity(roomName)) { Trace(playerId, "room=" + roomName + " is not an activity this server pays rewards for; paying the token package."); return BuildReward(rewardType, dto.Message, dto.GiftContext ?? GiftContext.GameRewards_Tokens); } var paid = RroRewardPools.ContextsForRoom(roomName); var claimed = dto.GiftContext; GiftContext context; if (claimed.HasValue && paid.Values.Contains(claimed.Value)) { context = claimed.Value; } else if (RroRewardPools.TryFloorContext(roomName, out var floor)) { context = floor; } else { return BuildReward(rewardType, dto.Message, GiftContext.GameRewards_Tokens); } if (RroRewardPools.PaysTickets(roomName)) { var games = LaserTagGamesToday.AddOrUpdate((playerId, DateOnly.FromDateTime(DateTime.UtcNow)), 1, (_, count) => count + 1); var tickets = RroRewardPools.LaserTagTicketsPerGame + (games <= RroRewardPools.LaserTagBonusGamesPerDay ? RroRewardPools.LaserTagFirstGamesBonus : 0); return TicketReward(rewardType, dto.Message, GiftContext.Lasertag, tickets); } var selection = RroRewardPools.OffersSelection(roomName); var owned = await LevelUpGiftService.CollectOwnedKeysAsync(db, playerId, context); var fromOwnPool = !RroRewardPools.PaysNoItemReward(roomName) && Random.Shared.NextDouble() <= RroRewardPools.DropChanceFor(context); var drops = fromOwnPool ? RroRewardPools.BuildDrops(context, selection, owned) : new List(); var source = context; var reacquired = drops.Count == 0; if (reacquired) { foreach (var weaker in RroRewardPools.LowerRanksOf(context)) { drops = RroRewardPools.BuildDrops(weaker, selection, owned); if (drops.Count == 0) continue; break; } } if (drops.Count == 0) { if (selection) { drops = RroRewardPools.BuildFallbackSelection(owned: owned, message: dto.Message); } else { drops = RroRewardPools.BuildFallbackDrops(selection: false, owned: owned); } } if (drops.Count == 0) { RewardTrace.Error(playerId, $"stage=Fallback activity={roomName} rank={RewardRankOf(context, roomName)} " + $"context={RewardTrace.Context(context)} pool={DescribeSets(context)} " + $"EligibleItems={RroRewardPools.Resolve(context).Count} UnownedItems=0 " + "Reason=neither the activity's pool nor the reacquisition pool has anything left to give; " + "paying the token package as the last resort."); return BuildReward(rewardType, dto.Message, context); } foreach (var drop in drops) drop.GiftDropId = StorefrontShared.NewGiftDropId(); var reward = new GameReward { RewardSelectionId = StorefrontShared.NewGiftDropId(), Message = string.IsNullOrWhiteSpace(dto.Message) ? "Game Reward" : dto.Message, GiftContext = source, RewardType = rewardType, GiftDrop1 = drops[0], GiftDrop2 = drops.Count > 1 ? drops[1] : drops[0], GiftDrop3 = drops.Count > 2 ? drops[2] : drops[0], CreatedAt = DateTime.UtcNow }; var eligible = RroRewardPools.Resolve(context); var unowned = eligible .Where(item => !owned.Contains(LevelUpRewardPool.OwnershipKey(item.AvatarItemDesc))) .ToList(); var boxKind = IsTokenBox(drops[0]) ? "Tokens" : "AvatarItem"; RewardTrace.Log(playerId, $"stage=Quest activity={roomName} rank={RewardRankOf(context, roomName)} " + $"context={RewardTrace.Context(context)} source={RewardTrace.Context(source)} rewardType={rewardType}"); RewardTrace.Log(playerId, $"stage=RewardPool context={RewardTrace.Context(context)} " + $"pool={DescribeSets(context)} EligibleItems={eligible.Count} {RewardTrace.Items(eligible)}"); RewardTrace.Log(playerId, $"stage=UnownedItems context={RewardTrace.Context(context)} UnownedItems={unowned.Count} {RewardTrace.Items(unowned)}"); RewardTrace.Log(playerId, $"stage=SelectedItem {RewardTrace.Boxes(drops)}"); RewardTrace.Log(playerId, $"stage=BoxCreated selectionId={reward.RewardSelectionId} " + $"boxType={boxKind} BoxRewardItem=\"{drops[0].AvatarItemDesc}\""); RewardTrace.Log(playerId, $"stage=GrantReward giftDropId={drops[0].GiftDropId} " + $"name=\"{drops[0].FriendlyName}\" item=\"{drops[0].AvatarItemDesc}\" " + $"consumable=\"{drops[0].ConsumableItemDesc}\" currency={drops[0].CurrencyType} {drops[0].Currency}"); RewardTrace.Log(playerId, $"stage=FinalReward rewardType={rewardType} context={RewardTrace.Context(source)} " + $"boxType={boxKind} firstTime={!reacquired}"); return reward; } private static readonly ConcurrentDictionary<(long PlayerId, DateOnly Day), int> LaserTagGamesToday = new(); private static GameReward TicketReward(RewardType type, string message, GiftContext context, int tickets) { var box = new Gift { GiftDropId = StorefrontShared.NewGiftDropId(), FriendlyName = "Laser Tag Tickets", Tooltip = string.IsNullOrWhiteSpace(message) ? "Tickets for playing!" : message, CurrencyType = CurrencyType.LaserTagTickets, Currency = tickets, Context = context, Rarity = Rarity.Common }; return new GameReward { RewardSelectionId = StorefrontShared.NewGiftDropId(), Message = string.IsNullOrWhiteSpace(message) ? "Laser Tag Tickets" : message, GiftContext = context, RewardType = type, GiftDrop1 = box, GiftDrop2 = box, GiftDrop3 = box, CreatedAt = DateTime.UtcNow }; } private static bool IsCompletion(RewardType type) => type is RewardType.FirstActivityOfDay or RewardType.PostGameActivity or RewardType.PostQuestActivity; private static GiftContext? BonusContext(RewardType type) => type switch { RewardType.DailyChallengesComplete => GiftContext.All_Daily_Challenges_Complete, RewardType.WeeklyCompleteButHaveSkin => GiftContext.All_Weekly_Challenge_Complete, RewardType.Levelup => GiftContext.LevelUp, _ => null }; private static GameReward BuildReward(RewardType type, string message, GiftContext context) { int baseTokens = type switch { RewardType.DailyChallengesComplete => 150, RewardType.WeeklyCompleteButHaveSkin => 300, RewardType.Levelup => 100, _ => RroRewardPools.CompletionTokenAmount }; return new GameReward { RewardSelectionId = StorefrontShared.NewGiftDropId(), Message = string.IsNullOrWhiteSpace(message) ? "Tokens" : message, GiftContext = context, RewardType = type, GiftDrop1 = RroRewardPools.TokenGift(context, baseTokens, message), GiftDrop2 = RroRewardPools.TokenGift(context, baseTokens * 2, message), GiftDrop3 = RroRewardPools.TokenGift(context, baseTokens * 3, message), CreatedAt = DateTime.UtcNow }; } private static async Task PushRewardSelectionAsync(long playerId, GameReward reward) { var number = Convert.ToInt64( RadicadeServer.RecNet.Enums.Messages.NotificationType.GiftPackageRewardSelectionReceived, System.Globalization.CultureInfo.InvariantCulture) .ToString(System.Globalization.CultureInfo.InvariantCulture); foreach (var id in new[] { RewardSelectionNotificationName, number }) { try { await RadicadeServer.API.Notifications.Notify.SendNotify(playerId, new RadicadeServer.RecNet.Notifications.Notification { Id = id, Msg = reward }); } catch (Exception) { } } } private static readonly TimeSpan ActivityVisitWindow = TimeSpan.FromHours(6); private string? _rawBody; private static void Trace(long playerId, string line) => RewardTrace.Log(playerId, line); private static string RewardRankOf(GiftContext context, string? roomName) { foreach (var (rank, paid) in RroRewardPools.ContextsForRoom(roomName)) { if (paid != context) continue; return string.IsNullOrEmpty(rank) ? "unranked" : rank; } return RroRewardPools.IsDeprecated(context) ? "deprecated" : "not paid by this room"; } private static string DescribeSets(GiftContext context) { var sets = RroRewardPools.SetsFor(context); if (sets.Count == 0) return ""; return string.Join(", ", sets.Select(set => string.IsNullOrEmpty(set.Colour) ? set.Family : $"{set.Family} {set.Colour}")); } private static bool IsTokenBox(Gift box) => string.IsNullOrWhiteSpace(box.AvatarItemDesc) && string.IsNullOrWhiteSpace(box.ConsumableItemDesc) && box.CurrencyType != CurrencyType.Invalid && box.Currency != 0; private const string RewardSelectionNotificationName = "RewardSelectionReceived"; } }