mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-27 07:59:03 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user