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

429 lines
15 KiB
C#

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<List<BulkConsumableResponse>?> GetUnlockedConsumables()
{
var userId = User.GetUserId();
var consumables = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
var owned = consumables?.GetConsumables() ?? new List<Consumable>();
var merged = new Dictionary<string, BulkConsumableResponse>(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();
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("getTransferable/{playerId:long}"), Authorize]
[HttpGet("getTransferable"), Authorize]
public async Task<List<BulkConsumableResponse>?> GetTransferable(long playerId = 0)
{
var owner = playerId > 0 ? playerId : User.GetUserId() ?? 0;
var merged = new Dictionary<string, BulkConsumableResponse>(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<Consumable>();
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;
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("transfer"), Authorize]
public async Task<IActionResult> 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<Consumable>();
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());
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("updateActive"), Authorize]
public async Task<IActionResult> 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();
}
/// <summary>
/// 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.
/// </summary>
private static Consumable? MatchConsumable(List<Consumable> owned, Dictionary<string, string> 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;
}
/// <summary>
/// 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.
/// </summary>
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();
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<string, string> 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<string, string> 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<string, string> fields, params string[] names)
{
var raw = Pick(fields, names);
return long.TryParse(raw, out var parsed) ? parsed : null;
}
private static bool? PickBool(Dictionary<string, string> 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<IActionResult> 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<ConsumeRequest?> 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<string, BulkConsumableResponse> 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<long> Ids { get; set; } = new List<long>();
public List<DateTime> CreatedAts { get; set; } = new List<DateTime>();
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; }
}
}
}