Files
2026-10-16 23:33:13 -07:00

547 lines
21 KiB
C#

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<Avatar?> 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<Avatar> 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<Avatar>() ?? new Avatar();
}
[HttpPost("set"), Authorize]
public async Task<Avatar?> 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<IActionResult> 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<IActionResult> 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<Gift>(), await GetPlayerLevelAsync(userId.Value));
}
var claimed = new List<Gift>();
var levelUpGifts = new List<Gift>();
var balanceGrants = new List<StorefrontShared.BalanceGrant>();
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<IActionResult> 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 ?? "<none>")} 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<Dictionary<string, string>> ReadGiftGenerateFieldsAsync()
{
var fields = new Dictionary<string, string>(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<string, string> 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<string, string> 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<string, string> 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<IActionResult> ConsumeGift(long giftDropId)
=> await ConsumePlayerGift(giftDropId, await ReadGiftIdsFromBodyAsync());
[HttpPost("gifts/consume"), Authorize]
public async Task<IActionResult> ConsumeGiftBody()
=> await ConsumePlayerGift(null, await ReadGiftIdsFromBodyAsync());
private async Task<List<long>> ReadGiftIdsFromBodyAsync()
{
var ids = new List<long>();
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<IActionResult> ConsumePlayerGift(long? giftDropId, List<long> bodyIds)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var requestedIds = new List<long>();
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<long>();
if (delivered.Count > 0)
{
return GiftClaimResponse(new List<Gift>(), await GetPlayerLevelAsync(userId.Value));
}
return NotFound(new { error = "unable_to_consume_missing_or_used_gift", giftDropId });
}
var consumed = new List<Gift>();
var levelUpGifts = new List<Gift>();
var balanceGrants = new List<StorefrontShared.BalanceGrant>();
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<int> GetPlayerLevelAsync(long playerId)
{
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
return progression?.Level ?? 1;
}
private async Task<List<long>> HandedOverBoxIdsAsync(long playerId, List<long> requestedIds)
{
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
var rewards = data?.GetProgressionJson()?.PendingGameRewards;
if (rewards == null) return new List<long>();
return rewards
.Where(r => r.ConsumedGiftDropId != 0 && requestedIds.Contains(r.ConsumedGiftDropId))
.Select(r => r.ConsumedGiftDropId)
.Distinct()
.ToList();
}
private IActionResult GiftClaimResponse(List<Gift> claimed, int unlockedLevel)
{
var body = new Dictionary<string, object?>
{
["consumed"] = claimed.Select(g => g.ForClient()).ToList(),
["UnlockedLevel"] = unlockedLevel
};
var first = claimed.FirstOrDefault();
if (first != null)
{
foreach (var kv in first.ForClient().ToJson().FromJson<Dictionary<string, object?>>() ?? new Dictionary<string, object?>())
body[kv.Key] = kv.Value;
}
return Ok(body);
}
private async Task<StorefrontShared.BalanceGrant?> GrantGift(long playerId, Gift gift)
{
return await StorefrontShared.GrantGiftAsync(db, playerId, gift);
}
private static async Task AnnounceBalancesAsync(long playerId, List<StorefrontShared.BalanceGrant> 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<long>? 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; }
}