mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Enhance avatar gifts and items handling; normalize gift descriptions and include color variants with unique IDs
This commit is contained in:
@@ -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,
|
||||
@@ -411,3 +432,4 @@ public class AvatarGiftsController(
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user