From 63e3b11736eb191e4a95410e795e451a5cadd7cc Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:54:01 +0300 Subject: [PATCH] feat: Enhance avatar gifts and items handling; normalize gift descriptions and include color variants with unique IDs --- DorkNet.Server.Tests/OwnedAvatarItemsTests.cs | 84 +++++++++++++++++++ .../API/Avatar/V2/AvatarGiftsController.cs | 30 ++++++- .../API/Avatar/V4/AvatarItemsController.cs | 38 +++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs index bcb0b53..6683526 100644 --- a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs +++ b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs @@ -1,6 +1,8 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace DorkNet.Server.Tests; @@ -131,6 +133,88 @@ public sealed class OwnedAvatarItemsTests : IClassFixture Assert.Equal(1, occurrences); } + [Fact] + public async Task Legacy_bare_desc_gift_is_served_as_4part_matching_v4items() + { + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + // Simulate a gift persisted BEFORE the fix: a bare-guid outfit desc + // (no ",,," suffix) — the class of row that made the post-consume + // UnlockAvatarItemAndMarkNew Find() miss and NRE. + string bareGuid; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-")); + bareGuid = item.Slug["wardrobe-".Length..]; + db.GiftPackages.Add(new Data.Entities.GiftPackageEntity + { + RecipientPlayerId = player.PlayerId, + AvatarItemType = 0, // outfit + AvatarItemDescOrHairDyeDesc = bareGuid, // BARE — the legacy bug + IsValid = true, + }); + await db.SaveChangesAsync(); + } + + using var client = ApiClient(player); + var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts"); + var gift = gifts.EnumerateArray() + .First(g => g.GetProperty("AvatarItemDesc").GetString()!.StartsWith(bareGuid)); + + // Both desc keys are served as the 4-part form regardless of how the + // row was stored, so the client's exact-match unlock succeeds. (A + // real buy also grants the item, so v4/items contains it — covered by + // Purchased_item_appears_owned / Buy_gift_desc_matches_v4items.) + var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!; + Assert.True(giftDesc.Count(c => c == ',') >= 3, $"legacy gift not normalized: {giftDesc}"); + Assert.Equal(giftDesc, gift.GetProperty("AvatarItemDescOrHairDyeDesc").GetString()); + } + + [Fact] + public async Task Toggle_on_wardrobe_includes_color_variants_with_unique_ids() + { + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var settings = scope.ServiceProvider + .GetRequiredService(); + await settings.SetAllAvatarItemsOwnedEnabledAsync(true); + } + + try + { + using var client = ApiClient(player); + var items = await GetJsonAsync(client, "/api/avatar/v4/items"); + + // Variant descs ("{guid},{combo},{mask},") — the colorways — are + // present, not just base "{guid},,," rows; and every id is unique + // (base + variants share a base guid, so a base-guid hash would + // collide → wardrobe dict crash). + var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList(); + var hasVariant = descs.Any(d => + { + var p = d.Split(','); + return p.Length >= 2 && p[1].Length > 0; + }); + Assert.True(hasVariant, "toggle-on wardrobe has no colored variants"); + + var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList(); + Assert.DoesNotContain(0, ids); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + finally + { + await using var scope = _factory.Services.CreateAsyncScope(); + await scope.ServiceProvider + .GetRequiredService() + .SetAllAvatarItemsOwnedEnabledAsync(false); + } + } + 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 d03ed5b..180db22 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs @@ -367,14 +367,35 @@ public class AvatarGiftsController( // GiftRarity, GiftContext. Missing any one → KeyNotFoundException // surfaces as "Failed to download gifts: Malformed Response" from // BootSequence and BLOCKS LOGIN. - public static object ToWire(GiftPackageEntity g) => new + /// The desc the client matches against api/avatar/v4/items in + /// OutfitManager.UnlockAvatarItemAndMarkNew after the gift box is + /// consumed. That endpoint emits outfit items as the 4-part "{guid},,," + /// form (a bare guid crashes the client's AvatarItem parser), and the + /// match is an EXACT string compare — so an outfit gift whose desc has + /// fewer than 4 comma-parts (legacy rows stored the bare guid) makes + /// Find() miss and NRE, which strips the item on consume. Pad outfit + /// descs to 4 parts here so BOTH new and already-persisted gifts match. + /// Hair dyes (AvatarItemType 1) match by bare colour guid — leave them; + /// currency-only gifts have an empty desc. + private static string NormalizeAvatarWireDesc(GiftPackageEntity g) { + var d = g.AvatarItemDescOrHairDyeDesc ?? string.Empty; + if (d.Length == 0 || g.AvatarItemType == 1) return d; + var parts = d.Split(',').Length; + return parts >= 4 ? d : d + new string(',', 4 - parts); + } + + public static object ToWire(GiftPackageEntity g) + { + var avatarDesc = NormalizeAvatarWireDesc(g); + return new + { g.Id, g.FromPlayerId, g.ConsumableItemDesc, g.AvatarItemType, - AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc, - g.AvatarItemDescOrHairDyeDesc, + AvatarItemDesc = avatarDesc, + AvatarItemDescOrHairDyeDesc = avatarDesc, g.EquipmentPrefabName, g.EquipmentModificationGuid, g.CurrencyType, @@ -409,5 +430,6 @@ public class AvatarGiftsController( g.ErrorMessage, g.SupportsCurrentPlatform, g.IsGifted, - }; + }; + } } diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs index 47f15db..7f358f8 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -220,6 +220,32 @@ public class AvatarItemsController( if (result.Count >= maxItems) return result; result.Add(item); } + // Colored variants aren't in the base avatar_item_lookup catalog, so + // without this the "everyone owns everything" wardrobe was missing all + // ~1.8k colorways — the player didn't own them AND buying one gifted a + // variant desc ("{guid},{combo},{mask},") that wasn't in this list, so + // the client's post-consume UnlockAvatarItemAndMarkNew Find() missed + // and NRE'd (the item vanished on buy). Emit each variant with its + // verbatim equip descriptor and a UNIQUE id hashed from the FULL + // descriptor (the base-guid hash would collide with the base item and + // every sibling colorway). + foreach (var v in StoreService.ColorVariantCatalog.Values) + { + if (result.Count >= maxItems) return result; + if (string.IsNullOrWhiteSpace(v.descriptor)) continue; + result.Add(new UnlockedAvatarItemDto + { + AvatarItemType = AvatarItemType.Outfit, + AvatarItemDesc = v.descriptor, + AvatarItemId = StableAvatarItemIdFull(v.descriptor), + PlatformMask = unchecked((int)0xFFFFFFFF), + FriendlyName = v.item_name ?? string.Empty, + Tooltip = string.Empty, + Rarity = 0, + TagList = string.Empty, + IsBaseAvatarItem = false, + }); + } foreach (var slug in StoreService.AllHairDyeSlugs) { if (result.Count >= maxItems) break; @@ -547,6 +573,18 @@ public class AvatarItemsController( return id == 0 ? 1 : id; } + /// Stable id from the FULL descriptor string (not just the base + /// guid). Colored variants share a base guid but are distinct wardrobe + /// entries, so they must hash the whole "{guid},{combo},{mask}," string + /// to get ids distinct from the base item and each other. + public static int StableAvatarItemIdFull(string? descriptor) + { + uint h = 2166136261u; + foreach (var c in descriptor ?? string.Empty) h = (h ^ c) * 16777619u; + var id = (int)(h & 0x7FFFFFFF); + return id == 0 ? 1 : id; + } + private static string PrettyName(string raw) { if (string.IsNullOrEmpty(raw)) return string.Empty;