using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.IO; using System.Text.Json; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Avatar; using RadicadeServer.API.Storefront; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.RecNet.Responses; namespace RadicadeServer.API.Avatars.V2 { [Route("api/consumables/v2")] [Route("econ/api/consumables/v2")] [Route("api/consumables/v1")] [Route("econ/api/consumables/v1")] public class ConsumablesV2 : Controller { RecDBContext db; public ConsumablesV2(RecDBContext context) { this.db = context; } [HttpGet("getUnlocked"), Authorize] public async Task?> GetUnlockedConsumables() { var userId = User.GetUserId(); var consumables = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId); var owned = consumables?.GetConsumables() ?? new List(); var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); if (LockerAccess.HasFullInventory(db, userId)) { foreach (var item in ConsumableCatalog.DefaultInstances(DBConfig.DefaultConsumableItems)) Add(merged, item); foreach (var item in owned) merged.Remove(ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc)); } foreach (var instance in owned) Add(merged, instance); return merged.Values.ToList(); } /// /// The free gift button's list: the consumables that may be handed to the named player. /// The client asks for this before it opens the box, so a 404 leaves the button sitting on /// "Processing" with nothing behind it and the screen never resolves. The body is the same /// bulk consumable the unlocked list serves - only IsTransferable differs, because that is /// the member the button filters on - and it answers even for an account that owns nothing, /// which is what keeps the box offered to a player who has never opened one. /// [HttpGet("getTransferable/{playerId:long}"), Authorize] [HttpGet("getTransferable"), Authorize] public async Task?> GetTransferable(long playerId = 0) { var owner = playerId > 0 ? playerId : User.GetUserId() ?? 0; var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in ConsumableCatalog.DefaultInstances(DBConfig.DefaultConsumableItems)) Add(merged, item); if (owner > 0) { var unlocks = await db.Unlocks.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == owner); var owned = unlocks?.GetConsumables() ?? new List(); foreach (var item in owned) merged.Remove(ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc)); foreach (var item in owned) Add(merged, item); } var transferable = merged.Values.ToList(); foreach (var entry in transferable) entry.IsTransferable = true; return transferable; } /// /// Hands one of the caller's consumables to another player - the half of the free gift /// button that getTransferable only sets up. The client posts the entry it showed and then /// waits for this answer; with no route the request 404s and the button stays on /// "Processing" with the gift already looking sent. One unit leaves the sender's stack (the /// entry is dropped when that was the last of it) and reaches the recipient as a gift /// package, the same box the friend box arrives in, so it appears on their watch without a /// relog. The answer is the two obfuscated members the client actually reads, not a /// PascalCase body it would treat as a failure. /// [HttpPost("transfer"), Authorize] public async Task TransferConsumable() { var senderId = User.GetUserId(); if (senderId == null) return Unauthorized(); var fields = await ReadBodyFieldsAsync(); var toPlayerId = PickLong(fields, "toPlayerId", "playerId", "playerIdTo", "recipientId", "targetPlayerId", "accountId"); if (toPlayerId is not > 0) return Ok(ClientAction.Failure("No player was named to receive the item.")); if (toPlayerId == senderId.Value) return Ok(ClientAction.Failure("You cannot hand a consumable to yourself.")); if (!await db.Players.AsNoTracking().AnyAsync(player => player.AccountId == toPlayerId.Value)) return Ok(ClientAction.Failure($"Player {toPlayerId.Value} does not exist.")); var sender = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == senderId.Value); var owned = sender?.GetConsumables() ?? new List(); var target = MatchConsumable(owned, fields); if (target == null || target.Count <= 0) return Ok(ClientAction.Failure("You do not own that consumable.")); var message = Pick(fields, "message", "tooltip", "note"); var box = new Gift { FriendlyName = target.ConsumableItemDesc, Tooltip = string.IsNullOrWhiteSpace(message) ? "A gift for you <3" : message.Trim(), FromPlayerId = senderId.Value, ConsumableItemDesc = ConsumableCatalog.ToClientDesc(target.ConsumableItemDesc), Context = GiftContext.Consumable }; if (target.InitialCount > 1) { target.InitialCount -= 1; target.Count = target.InitialCount; } else { owned.Remove(target); } sender!.SetConsumables(owned); await StorefrontShared.PersistGiftBoxAsync(db, toPlayerId.Value, box, notify: true); await Notify.SendAccountUpdate(senderId.Value); return Ok(ClientAction.Ok()); } /// /// Marks which of the player's consumables is the active one. The client asks for this as it /// enters a room, once per entry it wants switched on, and a 404 leaves the effect that the /// consumable bought (a hair dye, a rank-up potion) unapplied. Only the named entry is turned /// on, and turning one on is what turns the others off, so a second activation never leaves /// two effects claiming the same slot. The answer has no body, which is what the route's own /// wrapper returns. /// [HttpPost("updateActive"), Authorize] public async Task UpdateActive() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var fields = await ReadBodyFieldsAsync(); var activates = PickBool(fields, "isActive", "active", "activate", "enabled") ?? true; var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId.Value); if (unlocks == null) return Ok(); var consumables = unlocks.GetConsumables(); var target = MatchConsumable(consumables, fields); if (target == null) return Ok(); if (activates) { var targetDesc = ConsumableCatalog.ToClientDesc(target.ConsumableItemDesc); foreach (var other in consumables) { if (!string.Equals(ConsumableCatalog.ToClientDesc(other.ConsumableItemDesc), targetDesc, StringComparison.OrdinalIgnoreCase)) other.IsActive = false; } } target.IsActive = activates; unlocks.SetConsumables(consumables); await db.SaveChangesAsync(); return Ok(); } /// /// The entry the request names, by the id the unlocked list handed out and - when the client /// only echoed the description back - by its client token, alone or as one member of a list. /// private static Consumable? MatchConsumable(List owned, Dictionary fields) { var id = PickLong(fields, "id", "consumableItemId", "consumableId", "itemId", "consumableMappingId"); if (id is > 0) { var byId = owned.FirstOrDefault(c => c.Id == id.Value); if (byId != null) return byId; } var desc = Pick(fields, "consumableItemDesc", "desc", "descriptions", "consumableItem", "itemDesc"); if (string.IsNullOrWhiteSpace(desc)) return null; var wanted = ConsumableCatalog.ToClientDesc(desc); var candidates = wanted .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(part => !string.IsNullOrWhiteSpace(part)) .ToList(); if (candidates.Count == 0) candidates.Add(wanted); foreach (var candidate in candidates) { var byDesc = owned.FirstOrDefault(c => string.Equals(ConsumableCatalog.ToClientDesc(c.ConsumableItemDesc), candidate, StringComparison.OrdinalIgnoreCase) || string.Equals(c.ConsumableItemDesc, candidate, StringComparison.OrdinalIgnoreCase)); if (byDesc != null) return byDesc; } return null; } /// /// The request's members as a flat map. The body is a serialized request DTO whose members /// are not published, and the entry the client names sits one level down in a nested /// consumable, so every leaf is folded in by name instead of binding to a guessed shape; a /// form-encoded body is read the same way. /// 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(); if (Request.Body.CanSeek) Request.Body.Position = 0; if (raw.Length == 0) return fields; if (raw.StartsWith('{') || raw.StartsWith('[')) { using var document = JsonDocument.Parse(raw); Flatten(document.RootElement, fields); return fields; } foreach (var pair in raw.Split('&')) { var kv = pair.Split(new[] { '=' }, 2); if (kv.Length < 2) continue; fields[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]); } } catch (Exception) { } return fields; } private static void Flatten(JsonElement element, Dictionary into) { switch (element.ValueKind) { case JsonValueKind.Object: foreach (var property in element.EnumerateObject()) { if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) Flatten(property.Value, into); else into[property.Name] = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : property.Value.ToString(); } break; case JsonValueKind.Array: foreach (var item in element.EnumerateArray()) Flatten(item, into); break; } } private static string Pick(Dictionary fields, params string[] names) { foreach (var name in names) if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)) return value.Trim(); return string.Empty; } private static long? PickLong(Dictionary fields, params string[] names) { var raw = Pick(fields, names); return long.TryParse(raw, out var parsed) ? parsed : null; } private static bool? PickBool(Dictionary fields, params string[] names) { var raw = Pick(fields, names); if (raw.Length == 0) return null; return bool.TryParse(raw, out var parsed) ? parsed : null; } public class ConsumeRequest { public long Id { get; set; } public int DeltaCount { get; set; } = 1; } [HttpPost("consume"), Authorize] public async Task ConsumeConsumable() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var dto = await ReadConsumeRequestAsync(); if (dto == null || dto.Id <= 0) return Ok(); if (dto.DeltaCount <= 0) dto.DeltaCount = 1; var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId.Value); if (unlocks == null) { unlocks = new PlayerUnlocks { AccountId = userId.Value }; await db.Unlocks.AddAsync(unlocks); } var consumables = unlocks.GetConsumables(); var target = consumables.FirstOrDefault(c => c.Id == dto.Id); if (target == null) { return Ok(); } if (target.InitialCount > dto.DeltaCount) { target.InitialCount -= dto.DeltaCount; target.Count = target.InitialCount; } else { consumables.Remove(target); } unlocks.SetConsumables(consumables); await db.SaveChangesAsync(); return Ok(); } private async Task ReadConsumeRequestAsync() { 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 null; var dto = new ConsumeRequest(); if (raw.StartsWith('{')) { using var doc = JsonDocument.Parse(raw); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; foreach (var prop in doc.RootElement.EnumerateObject()) { if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) && long.TryParse(prop.Value.GetRawText(), out var id)) dto.Id = id; if (prop.Name.Equals("DeltaCount", StringComparison.OrdinalIgnoreCase) && int.TryParse(prop.Value.GetRawText(), out var delta)) dto.DeltaCount = delta; } return dto; } foreach (var pair in raw.Split('&')) { var kv = pair.Split(new[] { '=' }, 2); if (kv.Length < 2) continue; var key = Uri.UnescapeDataString(kv[0]); var value = Uri.UnescapeDataString(kv[1]); if (key.Equals("Id", StringComparison.OrdinalIgnoreCase) && long.TryParse(value, out var id)) dto.Id = id; if (key.Equals("DeltaCount", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var delta)) dto.DeltaCount = delta; } return dto; } catch (Exception) { return null; } } private static void Add(Dictionary merged, Consumable instance) { var desc = ConsumableCatalog.ToClientDesc(instance.ConsumableItemDesc); if (string.IsNullOrWhiteSpace(desc)) return; if (!merged.TryGetValue(desc, out var entry)) { entry = new BulkConsumableResponse { ConsumableItemDesc = desc }; merged[desc] = entry; } entry.Ids.Add(instance.Id); entry.CreatedAts.Add(instance.CreatedAt); entry.Count += instance.Count; entry.InitialCount += instance.InitialCount; entry.IsActive |= instance.IsActive; entry.ActiveDurationMinutes = Math.Max( entry.ActiveDurationMinutes ?? 0, instance.ActiveDurationMinutes); } public class BulkConsumableResponse { public List Ids { get; set; } = new List(); public List CreatedAts { get; set; } = new List(); public string ConsumableItemDesc { get; set; } = ""; public int Count { get; set; } public int InitialCount { get; set; } public bool IsActive { get; set; } public int? ActiveDurationMinutes { get; set; } public bool IsTransferable { get; set; } } } }