feat: Enhance avatar gifts and items handling; normalize gift descriptions and include color variants with unique IDs

This commit is contained in:
Alexa
2026-07-12 00:54:01 +03:00
parent c472e7f342
commit 63e3b11736
3 changed files with 148 additions and 4 deletions
@@ -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<DorkNetServerFactory>
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<Data.DorkNetDbContext>();
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<DorkNet.Server.Services.ServerSettingsService>();
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<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
@@ -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
/// <summary>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.</summary>
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,
};
};
}
}
@@ -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;
}
/// <summary>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.</summary>
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;