mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
429 lines
15 KiB
C#
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; }
|
|
}
|
|
}
|
|
}
|