diff --git a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs index 0ecdd2c..bcb0b53 100644 --- a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs +++ b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs @@ -83,6 +83,54 @@ public sealed class OwnedAvatarItemsTests : IClassFixture Assert.Equal(ids.Count, ids.Distinct().Count()); } + [Fact] + public async Task Buy_gift_desc_matches_v4items_and_consume_grants_once() + { + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var client = ApiClient(player); + + // Buy an avatar-backed item — the server queues a post-purchase gift box. + var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3"); + var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray() + .First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString())) + .GetProperty("PurchasableItemId").GetInt32(); + using var buy = await client.PostAsync("/api/storefronts/v2/buyItem", + new StringContent(JsonSerializer.Serialize(new + { + StorefrontType = 3, PurchasableItemId = purchasableId, + CurrencyType = 2, RequestedPrice = 1, Gift = (object?)null, + }), Encoding.UTF8, "application/json")); + buy.EnsureSuccessStatusCode(); + + var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts"); + Assert.True(gifts.GetArrayLength() > 0, "buy did not queue a gift box"); + var gift = gifts[0]; + var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!; + + // The gift's desc MUST be the same 4-part "{guid},,," form v4/items + // emits — the client matches on it verbatim in + // OutfitManager.UnlockAvatarItemAndMarkNew after the box is consumed; + // a bare guid missed and NRE'd ("items removed on every buy"). + Assert.True(giftDesc.Count(c => c == ',') >= 3, $"gift desc not 4-part: {giftDesc}"); + var items = await GetJsonAsync(client, "/api/avatar/v4/items"); + var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()).ToHashSet(); + Assert.Contains(giftDesc, descs); + + // Consuming the box must not add a SECOND wardrobe row for the item + // the direct purchase already granted as a bare guid. + using var consume = await client.PostAsync( + $"/api/avatar/v2/gifts/consume/{gift.GetProperty("Id").GetInt64()}", + new StringContent("{}", Encoding.UTF8, "application/json")); + consume.EnsureSuccessStatusCode(); + + var after = await GetJsonAsync(client, "/api/avatar/v4/items"); + var baseGuid = giftDesc.Split(',')[0]; + var occurrences = after.EnumerateArray() + .Count(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0] == baseGuid); + Assert.Equal(1, occurrences); + } + private HttpClient ApiClient(GameClientSession? session = null) { var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs index b5d83b4..d03ed5b 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs @@ -247,6 +247,12 @@ public class AvatarGiftsController( // empty list rather than 500. if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc)) { + // Inventory stores the BARE guid ("guid"), but a gift now carries + // the 4-part wire desc ("guid,,,") so its post-consume unlock + // matches api/avatar/v4/items. Normalise to the inventory form + // here or we'd store "guid,,," alongside the bare "guid" a direct + // purchase already added → a duplicate wardrobe row. + var invDesc = StoreService.InventoryAvatarItemDesc(gift.AvatarItemDescOrHairDyeDesc); var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid); if (avatar is null) { @@ -257,17 +263,16 @@ public class AvatarGiftsController( { var list = System.Text.Json.JsonSerializer .Deserialize>(avatar.InventoryJson) ?? new(); - if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc)) + if (!list.Contains(invDesc)) { - list.Add(gift.AvatarItemDescOrHairDyeDesc); + list.Add(invDesc); avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list); avatar.UpdatedAt = DateTime.UtcNow; } } catch { - avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize( - new[] { gift.AvatarItemDescOrHairDyeDesc }); + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { invDesc }); avatar.UpdatedAt = DateTime.UtcNow; } } diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs index 5aedf43..61d0f41 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs @@ -110,12 +110,21 @@ public class StorefrontsBuyController( inventoryAvatarDesc = hairDyeColorGuid; consumableItemDesc = hairDyeConsumableDesc; } + // The client matches this gift's item against api/avatar/v4/items by + // EXACT AvatarItemDesc string in OutfitManager.UnlockAvatarItemAndMarkNew + // (fired after the buyer consumes the post-purchase gift box). v4/items + // emits outfit items as the 4-part "{guid},,," desc (the bare guid + // crashes the client's AvatarItem parser), so the gift must carry the + // SAME 4-part desc — sending the stripped bare guid made Find() miss and + // NRE, which surfaced as items vanishing on every buy. Hair dyes match + // by their bare colour guid (inventoryAvatarDesc), unchanged. + var giftWireDesc = string.IsNullOrEmpty(avatarItemDesc) ? inventoryAvatarDesc : avatarItemDesc; var gift = new GiftPackageEntity { RecipientPlayerId = recipient, FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null, - AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType, - AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc, + AvatarItemType = string.IsNullOrEmpty(giftWireDesc) ? null : avatarItemType, + AvatarItemDescOrHairDyeDesc = giftWireDesc, ConsumableItemDesc = consumableItemDesc, EquipmentPrefabName = equipmentPrefabName, EquipmentModificationGuid = equipmentModificationGuid,