mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Add AvatarItemId, TagList, and IsBaseAvatarItem to owned avatar items; implement stable ID generation and corresponding tests
This commit is contained in:
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// api/avatar/v4/items — the owned/unlocked wardrobe. The 2023 client's
|
||||||
|
/// owned-item DTO (RecNet IFNGBPJGINL) requires <c>AvatarItemId</c> (Int32),
|
||||||
|
/// <c>TagList</c> (string) and <c>IsBaseAvatarItem</c> (bool). Omitting them
|
||||||
|
/// left every item at AvatarItemId 0; the client keys the wardrobe by that
|
||||||
|
/// id, so the second item collided, the unlocked-items list failed to build
|
||||||
|
/// (empty wardrobe = "no owned items"), and any refetch — including the one
|
||||||
|
/// after a purchase — NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew
|
||||||
|
/// ("can't buy items"). Each item must carry a unique, non-zero id.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public OwnedAvatarItemsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Owned_items_carry_unique_nonzero_ids_and_2023_keys()
|
||||||
|
{
|
||||||
|
using var setup = ApiClient();
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var client = ApiClient(player);
|
||||||
|
|
||||||
|
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||||
|
Assert.True(items.GetArrayLength() > 0, "starter wardrobe was empty");
|
||||||
|
|
||||||
|
var ids = new List<int>();
|
||||||
|
foreach (var it in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
// Every 2023 key present and correctly typed.
|
||||||
|
Assert.Equal(JsonValueKind.Number, it.GetProperty("AvatarItemId").ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.String, it.GetProperty("TagList").ValueKind);
|
||||||
|
var isBase = it.GetProperty("IsBaseAvatarItem").ValueKind;
|
||||||
|
Assert.True(isBase is JsonValueKind.True or JsonValueKind.False);
|
||||||
|
Assert.Equal(JsonValueKind.String, it.GetProperty("AvatarItemDesc").ValueKind);
|
||||||
|
|
||||||
|
var id = it.GetProperty("AvatarItemId").GetInt32();
|
||||||
|
Assert.NotEqual(0, id);
|
||||||
|
ids.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duplicate ids — a collision is what crashed the wardrobe.
|
||||||
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Purchased_item_appears_owned_with_valid_shape()
|
||||||
|
{
|
||||||
|
using var setup = ApiClient();
|
||||||
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
using var client = ApiClient(player);
|
||||||
|
|
||||||
|
var before = (await GetJsonAsync(client, "/api/avatar/v4/items")).GetArrayLength();
|
||||||
|
|
||||||
|
// Buy the first avatar-backed watch-storefront item.
|
||||||
|
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 after = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||||
|
Assert.True(after.GetArrayLength() >= before, "owned count shrank after purchase");
|
||||||
|
// Still no duplicate ids after the new item joins the list.
|
||||||
|
var ids = after.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||||
|
Assert.DoesNotContain(0, ids);
|
||||||
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient ApiClient(GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync(path);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,10 +173,13 @@ public class AvatarItemsController(
|
|||||||
{
|
{
|
||||||
AvatarItemType = item.AvatarItemType,
|
AvatarItemType = item.AvatarItemType,
|
||||||
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
||||||
|
AvatarItemId = item.AvatarItemId,
|
||||||
PlatformMask = item.PlatformMask,
|
PlatformMask = item.PlatformMask,
|
||||||
FriendlyName = item.FriendlyName,
|
FriendlyName = item.FriendlyName,
|
||||||
Tooltip = item.Tooltip,
|
Tooltip = item.Tooltip,
|
||||||
Rarity = item.Rarity,
|
Rarity = item.Rarity,
|
||||||
|
TagList = item.TagList,
|
||||||
|
IsBaseAvatarItem = item.IsBaseAvatarItem,
|
||||||
});
|
});
|
||||||
if (result.Count >= maxItems) break;
|
if (result.Count >= maxItems) break;
|
||||||
}
|
}
|
||||||
@@ -190,10 +193,13 @@ public class AvatarItemsController(
|
|||||||
{
|
{
|
||||||
AvatarItemType = AvatarItemType.HairDye,
|
AvatarItemType = AvatarItemType.HairDye,
|
||||||
AvatarItemDesc = hairColorGuid,
|
AvatarItemDesc = hairColorGuid,
|
||||||
|
AvatarItemId = StableAvatarItemId(hairColorGuid),
|
||||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||||
FriendlyName = friendlyName,
|
FriendlyName = friendlyName,
|
||||||
Tooltip = string.Empty,
|
Tooltip = string.Empty,
|
||||||
Rarity = 0,
|
Rarity = 0,
|
||||||
|
TagList = string.Empty,
|
||||||
|
IsBaseAvatarItem = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
@@ -223,10 +229,13 @@ public class AvatarItemsController(
|
|||||||
{
|
{
|
||||||
AvatarItemType = AvatarItemType.HairDye,
|
AvatarItemType = AvatarItemType.HairDye,
|
||||||
AvatarItemDesc = hairColorGuid,
|
AvatarItemDesc = hairColorGuid,
|
||||||
|
AvatarItemId = StableAvatarItemId(hairColorGuid),
|
||||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||||
FriendlyName = friendlyName,
|
FriendlyName = friendlyName,
|
||||||
Tooltip = string.Empty,
|
Tooltip = string.Empty,
|
||||||
Rarity = 0,
|
Rarity = 0,
|
||||||
|
TagList = string.Empty,
|
||||||
|
IsBaseAvatarItem = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -506,10 +515,13 @@ public class AvatarItemsController(
|
|||||||
// GUID as a color GUID and log "Can not find hair dye color".
|
// GUID as a color GUID and log "Can not find hair dye color".
|
||||||
AvatarItemType = AvatarItemType.Outfit,
|
AvatarItemType = AvatarItemType.Outfit,
|
||||||
AvatarItemDesc = row.Guid + ",,,",
|
AvatarItemDesc = row.Guid + ",,,",
|
||||||
|
AvatarItemId = StableAvatarItemId(row.Guid),
|
||||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||||
FriendlyName = pretty,
|
FriendlyName = pretty,
|
||||||
Tooltip = string.Empty,
|
Tooltip = string.Empty,
|
||||||
Rarity = 0,
|
Rarity = 0,
|
||||||
|
TagList = string.Empty,
|
||||||
|
IsBaseAvatarItem = false,
|
||||||
};
|
};
|
||||||
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
||||||
bySlot[row.OutfitType] = lst = new List<string>();
|
bySlot[row.OutfitType] = lst = new List<string>();
|
||||||
@@ -520,6 +532,21 @@ public class AvatarItemsController(
|
|||||||
return new CatalogData(items, safe, bySlot, slotByGuid);
|
return new CatalogData(items, safe, bySlot, slotByGuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
|
||||||
|
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client
|
||||||
|
/// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
|
||||||
|
/// item and stable across restarts. Colored variants reuse their base
|
||||||
|
/// item's id (a player owns the base OR a variant, never both in the
|
||||||
|
/// list), so no collision.</summary>
|
||||||
|
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
|
||||||
|
{
|
||||||
|
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
|
||||||
|
uint h = 2166136261u;
|
||||||
|
foreach (var c in guid) h = (h ^ c) * 16777619u;
|
||||||
|
var id = (int)(h & 0x7FFFFFFF);
|
||||||
|
return id == 0 ? 1 : id;
|
||||||
|
}
|
||||||
|
|
||||||
private static string PrettyName(string raw)
|
private static string PrettyName(string raw)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
||||||
@@ -618,6 +645,16 @@ public class UnlockedAvatarItemDto
|
|||||||
[JsonPropertyName("AvatarItemDesc")]
|
[JsonPropertyName("AvatarItemDesc")]
|
||||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The 2023 client's owned-item DTO (RecNet IFNGBPJGINL) reads
|
||||||
|
/// <c>AvatarItemId</c> as an <b>Int32</b>. Omitting it left every owned
|
||||||
|
/// item at id 0; the client keys its wardrobe by AvatarItemId, so a
|
||||||
|
/// second item collided and the whole unlocked-items list failed to
|
||||||
|
/// build — the wardrobe showed nothing owned and buying (which refetches
|
||||||
|
/// this list) NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew. Emit a
|
||||||
|
/// stable, unique, non-zero id.</summary>
|
||||||
|
[JsonPropertyName("AvatarItemId")]
|
||||||
|
public int AvatarItemId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("PlatformMask")]
|
[JsonPropertyName("PlatformMask")]
|
||||||
public int PlatformMask { get; set; }
|
public int PlatformMask { get; set; }
|
||||||
|
|
||||||
@@ -629,4 +666,15 @@ public class UnlockedAvatarItemDto
|
|||||||
|
|
||||||
[JsonPropertyName("Rarity")]
|
[JsonPropertyName("Rarity")]
|
||||||
public int Rarity { get; set; }
|
public int Rarity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>CSV tag string (IFNGBPJGINL.TagList). Empty is fine —
|
||||||
|
/// AvatarItemMetadata.ParseTagList tolerates it — but the key must be
|
||||||
|
/// present and a string, never null.</summary>
|
||||||
|
[JsonPropertyName("TagList")]
|
||||||
|
public string TagList { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether this is a default base-body item vs a cosmetic
|
||||||
|
/// (IFNGBPJGINL.IsBaseAvatarItem). False for wardrobe/catalog items.</summary>
|
||||||
|
[JsonPropertyName("IsBaseAvatarItem")]
|
||||||
|
public bool IsBaseAvatarItem { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user