using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Models.Notification; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Inventory; /// /// api.rec.net/api/{equipment,consumables}/* — backs the watch's /// Backpack tab. Wire DTOs match the decompiled client classes: /// /// • RecNet.Equipment — fields: PrefabName, ModificationGuid, /// PlatformMask, IsPlatformLocked, FriendlyName, Tooltip, Rarity, /// Favorited. /// • RecNet.Consumable — fields: Id(long), ConsumableType, /// PlatformMask, Count, InitialCount, UnlockedLevel, CreatedAt, /// ActiveDurationMinutes(int?), ConsumableItem(nested), Category, /// IsActive, IsPlatformLocked. /// /// Persistence: one row per /// (player, item). For equipment, Quantity=1 + IsActive tracks /// favourite. For consumables, Quantity is the stack count + IsActive /// tracks "currently activated". /// [ApiController] [Authorize] public class InventoryController( DorkNetDbContext db, NotificationService notifications) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); // Categories the StoreItem table uses for equipment vs // consumables. The seed data tags items per these strings. private static readonly HashSet EquipmentCategories = new() { "equipment", "tool", "maker_pen", "weapon", }; private static readonly HashSet ConsumableCategories = new() { "consumable", }; // ── Equipment ──────────────────────────────────────────────────────── [HttpGet("api/equipment/v1/getUnlocked")] [HttpGet("api/equipment/v2/getUnlocked")] [HttpGet("api/equipment/v3/getUnlocked")] public async Task GetUnlockedEquipment() { var pid = Me; var rows = await (from inv in db.PlayerInventory join item in db.StoreItems on inv.ItemSlug equals item.Slug where inv.PlayerId == pid && EquipmentCategories.Contains(item.Category) select new { inv, item }).ToListAsync(); return Ok(rows.Select(r => ToEquipmentWire(r.inv, r.item))); } public sealed class EquipmentUpdateRequest { public string PrefabName { get; set; } = string.Empty; public string ModificationGuid { get; set; } = string.Empty; public bool Favorited { get; set; } } /// POST api/equipment/v1/update — toggle the /// "favorited" flag on a piece of equipment (the watch lets the /// player pin commonly-used tools). [HttpPost("api/equipment/v1/update")] public async Task UpdateEquipment([FromBody] EquipmentUpdateRequest req) { var pid = Me; // The slug for equipment is the modification GUID (or prefab // name fallback when no mod is applied). var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid) ? req.ModificationGuid : req.PrefabName; if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier"); var row = await db.PlayerInventory .FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug); if (row is null) { // Player doesn't own this; reject rather than silently // creating ownership (avoids letting clients grant items // to themselves). return Forbid(); } row.IsActive = req.Favorited; await db.SaveChangesAsync(); return Ok(new { Updated = true }); } // ── Consumables ────────────────────────────────────────────────────── [HttpGet("api/consumables/v1/getUnlocked")] public async Task GetUnlockedConsumables() { var pid = Me; var rows = await (from inv in db.PlayerInventory join item in db.StoreItems on inv.ItemSlug equals item.Slug where inv.PlayerId == pid && ConsumableCategories.Contains(item.Category) select new { inv, item }).ToListAsync(); return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item))); } public sealed class ConsumableItemRequest { public long Id { get; set; } public int DeltaCount { get; set; } } [HttpPost("api/consumables/v1/consume")] public async Task Consume([FromBody] ConsumableItemRequest req) { var pid = Me; var row = await db.PlayerInventory .FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id); if (row is null) return NotFound(); var delta = Math.Max(1, Math.Abs(req.DeltaCount)); row.Quantity = Math.Max(0, row.Quantity - delta); if (row.Quantity == 0) db.PlayerInventory.Remove(row); await db.SaveChangesAsync(); await notifications.NotifyAsync(pid, row.Quantity > 0 ? PushNotificationId.ConsumableMappingAdded : PushNotificationId.ConsumableMappingRemoved, new { row.Id, row.ItemSlug, row.Quantity }); return Ok(new { row.Id, Count = row.Quantity }); } public sealed class ActivateConsumableRequest { public long Id { get; set; } public bool IsActive { get; set; } } [HttpPost("api/consumables/v1/updateActive")] public async Task UpdateActive([FromBody] ActivateConsumableRequest req) { var pid = Me; var row = await db.PlayerInventory .FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id); if (row is null) return NotFound(); row.IsActive = req.IsActive; await db.SaveChangesAsync(); return Ok(new { row.Id, row.IsActive }); } // ── Wire serializers ───────────────────────────────────────────────── private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) => new { PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name ModificationGuid = inv.ItemSlug, PlatformMask = -1, // All IsPlatformLocked = false, FriendlyName = item.DisplayName, Tooltip = item.Description, Rarity = 0, // Common (no rarity column on StoreItem yet) Favorited = inv.IsActive, }; private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item) { var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc) ? consumableDesc : item.Slug; var category = StoreService.TryGetHairDyePayload(item.Slug, out _) ? 6 : 0; return new { inv.Id, ConsumableItemDesc = type, ConsumableType = type, PlatformMask = -1, Count = inv.Quantity, InitialCount = inv.Quantity, // we don't track original purchase count UnlockedLevel = 0, CreatedAt = inv.AcquiredAt, ActiveDurationMinutes = (int?)null, ConsumableItem = new { Id = inv.Id, Type = type, FriendlyName = item.DisplayName, Tooltip = item.Description, ImageName = item.ImageName, }, Category = category, inv.IsActive, IsPlatformLocked = false, }; } }