mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Update gift handling to ensure AvatarItemDesc matches v4/items format; prevent duplicate wardrobe entries
This commit is contained in:
@@ -83,6 +83,54 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
|||||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
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)
|
private HttpClient ApiClient(GameClientSession? session = null)
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
|||||||
@@ -247,6 +247,12 @@ public class AvatarGiftsController(
|
|||||||
// empty list rather than 500.
|
// empty list rather than 500.
|
||||||
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
|
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);
|
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||||
if (avatar is null)
|
if (avatar is null)
|
||||||
{
|
{
|
||||||
@@ -257,17 +263,16 @@ public class AvatarGiftsController(
|
|||||||
{
|
{
|
||||||
var list = System.Text.Json.JsonSerializer
|
var list = System.Text.Json.JsonSerializer
|
||||||
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
.Deserialize<List<string>>(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.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
|
||||||
avatar.UpdatedAt = DateTime.UtcNow;
|
avatar.UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
|
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { invDesc });
|
||||||
new[] { gift.AvatarItemDescOrHairDyeDesc });
|
|
||||||
avatar.UpdatedAt = DateTime.UtcNow;
|
avatar.UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,12 +110,21 @@ public class StorefrontsBuyController(
|
|||||||
inventoryAvatarDesc = hairDyeColorGuid;
|
inventoryAvatarDesc = hairDyeColorGuid;
|
||||||
consumableItemDesc = hairDyeConsumableDesc;
|
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
|
var gift = new GiftPackageEntity
|
||||||
{
|
{
|
||||||
RecipientPlayerId = recipient,
|
RecipientPlayerId = recipient,
|
||||||
FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null,
|
FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null,
|
||||||
AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType,
|
AvatarItemType = string.IsNullOrEmpty(giftWireDesc) ? null : avatarItemType,
|
||||||
AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc,
|
AvatarItemDescOrHairDyeDesc = giftWireDesc,
|
||||||
ConsumableItemDesc = consumableItemDesc,
|
ConsumableItemDesc = consumableItemDesc,
|
||||||
EquipmentPrefabName = equipmentPrefabName,
|
EquipmentPrefabName = equipmentPrefabName,
|
||||||
EquipmentModificationGuid = equipmentModificationGuid,
|
EquipmentModificationGuid = equipmentModificationGuid,
|
||||||
|
|||||||
Reference in New Issue
Block a user