Files
Radicade/Server/ProgressionEvents/GameRewardsAPI.cs
T
2026-10-16 23:33:13 -07:00

604 lines
21 KiB
C#

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<List<GameReward>> GetPendingGameRewards()
{
var userId = User.GetUserId();
if (userId == null) return new List<GameReward>();
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<GameReward>();
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<ActionResult<OkResponse>> 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<GameReward>();
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<GameReward> 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<ActionResult<OkResponse>> 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<GameRewardRequestDTO?> 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<RewardType>(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<string, string> 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<GiftContext>(raw.Trim(), true, out var named) ? named : null;
}
private async Task<GameRewardSelectDTO?> 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<Dictionary<string, string>> ReadBodyFieldsAsync()
{
var fields = new Dictionary<string, string>(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<GameReward> 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<Gift>();
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 "<none configured>";
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";
}
}