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.Avatar.V2; /// /// api.rec.net/api/avatar/v2/gifts — wardrobe gift inbox. Mirrors /// the client's RecNet.Avatars.DowloadGiftPackages + /// LocalRequestGiftPackage + LocalConsumeGiftPackage /// flow. Wire DTO matches RecNet.Avatars+GiftPackage /// (Cpp2IL_CS/.../RecNet/Avatars.cs:192-555) field-for-field /// so the watch's deserialiser doesn't crash. /// /// Server semantics: /// - GET v2/gifts returns unconsumed gifts for caller. /// - POST v2/gifts/generate rolls a random unlocked-avatar /// item and stamps a for caller — /// used by drop tiles and "free daily" prompts. /// - POST v2/gifts/consume/{id} marks consumed and appends /// the /// to the player's so it /// shows up in their backpack on next load. /// [ApiController] [Authorize] public class AvatarGiftsController( DorkNetDbContext db, NotificationService notifications, LevelService level) : ControllerBase { [HttpGet("api/avatar/v2/gifts")] [HttpGet("api/avatar/v3/gifts")] [HttpGet("api/avatar/v4/gifts")] public async Task List() { var pid = this.RequireCurrentPlayerId(); var rows = await db.GiftPackages .Where(g => g.RecipientPlayerId == pid && !g.Consumed) .OrderByDescending(g => g.CreatedAt) .ToListAsync(); return Ok(rows.Select(ToWire)); } public sealed record GenerateGiftRequest(int? GiftContext); /// POST api/avatar/v2/gifts/generate — server picks /// a random unlocked-avatar item and queues a gift package. The /// 2020 client posts to this when the user clicks a free-gift /// tile or hits a context the client maps to GiftContext. [HttpPost("api/avatar/v2/gifts/generate")] public async Task Generate([FromBody] GenerateGiftRequest? req) { var pid = this.RequireCurrentPlayerId(); // Pick a random wardrobe StoreItem (Slug starts with // "wardrobe-" — that's the convention StoreService uses for // catalogue rows derived from outfits_assets bundles). var pool = await db.StoreItems .Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-")) .Select(s => new { s.Slug, s.DisplayName }) .ToListAsync(); if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue"); var rng = new Random(); var pick = pool[rng.Next(pool.Count)]; var guid = pick.Slug["wardrobe-".Length..]; // Bare GUIDs crash AvatarItem.FromRecNetString — append `,,,` // so the watch's parse-and-iterate doesn't blow up in // GiftManager.DequeueGift. See AvatarItemsController.cs:31-40 // for the canonical wire-format note. var gift = new GiftPackageEntity { RecipientPlayerId = pid, FromPlayerId = null, AvatarItemType = 0, // Outfit AvatarItemDescOrHairDyeDesc = guid + ",,,", CurrencyType = 0, // Invalid (no currency in this gift) GiftContext = req?.GiftContext ?? 0, GiftRarity = RollRarity(rng), Message = $"Enjoy your new {pick.DisplayName}!", Platform = -1, PackageVariant = "Standard", PackageMaterial = string.Empty, IsValid = true, SupportsCurrentPlatform = true, }; db.GiftPackages.Add(gift); await db.SaveChangesAsync(); // Push so the watch shows the new-gift toast. await notifications.NotifyAsync(pid, PushNotificationId.GiftPackageReceived, ToWire(gift)); return Ok(ToWire(gift)); } [HttpPost("api/avatar/v2/gifts/consume/{id:long}")] public Task ConsumeViaPath(long id) => ConsumeImpl(id); /// POST /api/avatar/v2/gifts/consume/ (note trailing /// slash, no id in path). The watch's LocalConsumeGiftPackage /// (verified at Cpp2IL_ISIL/.../RecNet/Avatars_NestedType__LocalConsumeGiftPackage_d__19.txt:276-301) /// posts to this exact URL with a form-urlencoded body containing /// Id and UnlockedLevel fields. The previous /// implementation took [FromBody] ConsumeGiftRequest which /// only accepts application/json — the watch's form-encoded /// body got 415 Unsupported Media Type /// (output_log.txt:'Failed to consume gift … HTTP Error 415'), /// which left the gift unconsumed → the avatar item never landed /// in the player's inventory → mirror-unequip rendered a missing /// item and crashed. /// /// Accept both JSON and form-urlencoded by reading the raw body /// and pulling Id from whichever container we got. [HttpPost("api/avatar/v2/gifts/consume")] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data", "text/plain")] public async Task ConsumeViaBody() { long id = 0; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); var raw = form["Id"].ToString(); if (string.IsNullOrWhiteSpace(raw)) raw = form["id"].ToString(); long.TryParse(raw, out id); } else { using var reader = new System.IO.StreamReader(Request.Body); var body = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(body)) { try { using var doc = System.Text.Json.JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("Id", out var v) || doc.RootElement.TryGetProperty("id", out v)) { if (v.ValueKind == System.Text.Json.JsonValueKind.Number) v.TryGetInt64(out id); else if (v.ValueKind == System.Text.Json.JsonValueKind.String) long.TryParse(v.GetString(), out id); } } catch (System.Text.Json.JsonException) { /* fall through; id stays 0 */ } } } if (id == 0) { var raw = Request.Query["Id"].ToString(); if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["id"].ToString(); long.TryParse(raw, out id); } if (id == 0) return BadRequest("missing Id"); return await ConsumeImpl(id); } private async Task ConsumeImpl(long id) { var pid = this.RequireCurrentPlayerId(); var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id); if (gift is null) return NotFound(); if (gift.RecipientPlayerId != pid) return Forbid(); if (gift.Consumed) return Ok(ToWire(gift)); // idempotent // Append avatar item to player's inventory list (JSON list of // GUID strings). Defensive parse — corrupted JSON resets to // empty list rather than 500. if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc)) { var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid); if (avatar is null) { avatar = new AvatarEntity { PlayerId = pid }; db.Avatars.Add(avatar); } try { var list = System.Text.Json.JsonSerializer .Deserialize>(avatar.InventoryJson) ?? new(); if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc)) { list.Add(gift.AvatarItemDescOrHairDyeDesc); avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list); avatar.UpdatedAt = DateTime.UtcNow; } } catch { avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize( new[] { gift.AvatarItemDescOrHairDyeDesc }); avatar.UpdatedAt = DateTime.UtcNow; } } // Apply currency / XP / level rewards bundled with the gift. // The previous implementation only handled the avatar item path, // so token gifts from the admin SPA marked themselves consumed // but never moved the player's wallet — the player tapped the // gift, the box played its animation, and the balance stayed // unchanged. CurrencyType is the canonical RecNet enum // (1 = Tokens, 2 = Coins; 0 means "no currency in this gift"). if (gift.CurrencyType > 0 && gift.Currency > 0) { await level.GrantCurrencyAsync(pid, gift.CurrencyType, gift.Currency, $"gift_consumed:{gift.Id}"); } if (gift.Xp > 0) { await level.AwardXpAsync(pid, gift.Xp, $"gift_consumed:{gift.Id}"); } // Direct level grants bypass XP curves — same pattern // /api/admin/v1/players/{id}/levelup uses. We don't surface // that as a separate helper; bump the column inline. if (gift.Level > 0) { var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); if (player is not null) player.Level += gift.Level; } gift.Consumed = true; gift.ConsumedAt = DateTime.UtcNow; await db.SaveChangesAsync(); // Profile-update push so the watch's wallet readout refreshes // without waiting for the next /balance poll. Mirrors what // /api/admin/v1/players/{id}/gift does after the inbox push so // the toast number animates up the moment the box closes. if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0) { await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateProfile, new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level }); } return Ok(ToWire(gift)); } /// Weighted rarity roll — Common 65, Uncommon 25, /// Rare 8, Epic 1.8, Legendary 0.2 percent. private static int RollRarity(Random rng) => rng.NextDouble() switch { < 0.65 => 0, // Common < 0.90 => 10, // Uncommon < 0.98 => 20, // Rare < 0.998 => 30, // Epic _ => 50, // Legendary }; private static object ToWire(GiftPackageEntity g) => new { g.Id, g.FromPlayerId, g.ConsumableItemDesc, g.AvatarItemType, AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc, g.AvatarItemDescOrHairDyeDesc, g.EquipmentPrefabName, g.EquipmentModificationGuid, g.CurrencyType, g.Currency, g.Xp, g.Level, g.GiftContext, g.GiftRarity, g.Message, g.Platform, g.PackageMaterial, g.PackageVariant, g.Consumed, g.IsValid, g.ErrorMessage, g.SupportsCurrentPlatform, g.IsGifted, }; }