Files
Radicade/ServerTests/AvatarGiftGenerateTests.cs
2026-10-16 23:33:13 -07:00

417 lines
19 KiB
C#

using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Avatars.V2;
using RadicadeServer.API.Avatars.V3;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Storefront;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
public class AvatarGiftGenerateTests
{
private const long PlayerId = 6812;
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GenerateGiftAcceptsTheEconFormContractAndPersistsAnOpenableGift(bool useV3)
{
var options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new RecDBContext(options);
Controller controller = useV3 ? new AvatarV3(db) : new AvatarV2(db);
SetUserAndFormBody(controller,
$"MFPELEJLJLD={(int)GiftContext.Game_Drop}&CIEHFJEIOJJ={(int)GiftContext.GameRewards_Tokens}&HNKBFLPDLOC=true&DPAJDIEBNKM=earned+in+game&Xp=17");
var result = Assert.IsType<OkObjectResult>(await (useV3
? ((AvatarV3)controller).GenerateGift()
: ((AvatarV2)controller).GenerateGift()));
var gift = Assert.IsType<Gift>(result.Value);
Assert.True(gift.GiftDropId > 0);
Assert.Equal(PlayerId, gift.FromPlayerId);
Assert.Equal(GiftContext.Game_Drop, gift.Context);
Assert.Equal("earned in game", gift.Tooltip);
Assert.Equal(Rarity.Rare, gift.Rarity);
Assert.Equal(17, gift.Xp);
Assert.InRange(gift.Currency, 10, 500);
var pending = await StorefrontShared.GetPendingGiftsAsync(db, PlayerId);
var stored = Assert.Single(pending);
Assert.Equal(gift.GiftDropId, stored.GiftDropId);
Assert.Equal(gift.GiftDropId, stored.ForClient().Id);
Assert.Equal(gift.Currency, stored.Currency);
Assert.Equal(gift.Rarity, stored.Rarity);
}
[Fact]
public async Task GenerateGiftAcceptsFriendlyJsonAndRejectsUnknownContexts()
{
var options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new RecDBContext(options);
var api = new AvatarV2(db);
SetUserAndJsonBody(api, "{\"GiftContext\":2,\"AlternateGiftContext\":51,\"IsGameGift\":false,\"Message\":\"daily reward\",\"Xp\":9}");
var result = Assert.IsType<OkObjectResult>(await api.GenerateGift());
var gift = Assert.IsType<Gift>(result.Value);
Assert.Equal(GiftContext.Game_Drop, gift.Context);
Assert.Equal("daily reward", gift.Tooltip);
Assert.Equal(9, gift.Xp);
Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId));
var invalid = new AvatarV2(db);
SetUserAndFormBody(invalid, "MFPELEJLJLD=999999&DPAJDIEBNKM=bad");
Assert.IsType<BadRequestObjectResult>(await invalid.GenerateGift());
Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId));
}
/// <summary>
/// A gift box holding an item becomes that item, and it keeps its name.
///
/// The unlock used to be written from the descriptor alone - <c>new AvatarItem { AvatarItemDesc }</c>
/// - so everything else about the item was missing: the wardrobe listed a nameless, id-less,
/// thumbnail-less entry. It read as a copy of the item rather than the item, because that is what
/// it was. The gift's own name and rarity are what an unlock this server can resolve has to
/// carry, and the catalogue's own record is used where the catalogue has one (see the test
/// below).
/// </summary>
[Fact]
public async Task AGrantedItemUnlockKeepsTheNameTheRewardWasGivenUnder()
{
await using var db = new RecDBContext(TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
var gift = new Gift
{
GiftDropId = 4711,
FriendlyName = "Bandana (Flowers, Black)",
Tooltip = "First Game of the Day",
AvatarItemDesc = "9ff1f0ff-a0d6-4b3c-9b7c-6c0e5a1b2c3d,44d2f8a1-0000-0000-0000-000000000000",
Rarity = Rarity.Uncommon,
CurrencyType = CurrencyType.Invalid
};
await StorefrontShared.GrantGiftAsync(db, PlayerId, gift);
await db.SaveChangesAsync();
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == PlayerId);
var item = Assert.Single(unlocks!.GetAvatarItems(), x => x.AvatarItemDesc == gift.AvatarItemDesc);
Assert.Equal("Bandana (Flowers, Black)", item.FriendlyName);
Assert.Equal("First Game of the Day", item.Tooltip);
Assert.Equal((int)Rarity.Uncommon, item.Rarity);
}
/// <summary>
/// An item the shipped catalogue carries is granted as the catalogue's own record: the name, the
/// item id, the type and the thumbnail the client draws it from, not just the descriptor. This is
/// what "the ACTUAL item instead of a cloned item" is.
/// </summary>
[Fact]
public void TheCatalogueResolvesAGrantedItemToItsOwnRecord()
{
var catalogue = JsonSerializer.Serialize(new List<AvatarItem>
{
new()
{
AvatarItemId = 55,
AvatarItemDesc = "abc123,def456",
FriendlyName = "Bandana (Flowers, Black)",
Tooltip = "Bandana (Flowers, Black)",
Rarity = 20,
AvatarItemType = 1,
ThumbnailImage = "thumb.png",
CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
}
});
var item = AvatarUnlockCatalog.FindItem("abc123,def456", catalogue);
Assert.NotNull(item);
Assert.Equal("abc123,def456", item!.AvatarItemDesc);
Assert.Equal("Bandana (Flowers, Black)", item.FriendlyName);
Assert.Equal(55, item.AvatarItemId);
Assert.Equal(20, item.Rarity);
Assert.Equal(1, item.AvatarItemType);
Assert.Equal("thumb.png", item.ThumbnailImage);
// A descriptor the catalogue does not carry resolves to nothing, so the grant path falls back
// to the gift's own name rather than writing a nameless unlock.
Assert.Null(AvatarUnlockCatalog.FindItem("not-in-the-catalogue", catalogue));
}
/// <summary>
/// A generate for a reward the player is still holding hands over THAT item, not a token box.
///
/// The client collects a finished activity through this route and never names the item - the
/// request carries the reward's context and nothing else - so the item is whatever the reward
/// selection is holding. Generating a fresh random token box here is why a completed Stunt
/// Runner run or quest paid tokens while its selection screen showed the right items.
/// </summary>
[Fact]
public async Task GenerateGiftHandsOverTheItemTheOutstandingRewardIsHolding()
{
await using var db = new RecDBContext(TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
var item = new Gift
{
GiftDropId = 9001,
FriendlyName = "Archer Hat (Tan)",
Tooltip = "Archer Hat (Tan)",
AvatarItemDesc = "d2d3264b-fbf6-40b5-9f10-c85ec737d112,9b35ae52-febe-486a-8557-ec7190a9756e",
CurrencyType = CurrencyType.Invalid,
Currency = 0,
Rarity = Rarity.Uncommon,
Context = GiftContext.Quest_Goblin_C
};
var reward = new GameReward
{
RewardSelectionId = 4242,
Message = "First Game of the Day",
GiftContext = GiftContext.Quest_Goblin_C,
RewardType = RewardType.PostGameActivity,
GiftDrop1 = item,
GiftDrop2 = item,
GiftDrop3 = item,
CreatedAt = new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc)
};
var json = new ProgressionJson { PendingGameRewards = new List<GameReward> { reward } };
db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Progress = json.ToJson() });
await db.SaveChangesAsync();
var api = new AvatarV2(db);
SetUserAndFormBody(api, $"MFPELEJLJLD={(int)GiftContext.Quest_Goblin_C}&DPAJDIEBNKM=First+Game+of+the+Day");
var result = Assert.IsType<OkObjectResult>(await api.GenerateGift());
var gift = Assert.IsType<Gift>(result.Value);
// The item the selection was showing, not a package of Rec Center tokens.
Assert.Equal("Archer Hat (Tan)", gift.FriendlyName);
Assert.Equal(item.AvatarItemDesc, gift.AvatarItemDesc);
Assert.Equal(CurrencyType.Invalid, gift.CurrencyType);
Assert.Equal(0, gift.Currency);
Assert.Equal(GiftContext.Quest_Goblin_C, gift.Context);
// The collection is taken, not copied: the reward is completed and remembers the box.
var stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId);
var saved = Assert.Single(stored.GetProgressionJson()!.PendingGameRewards);
Assert.True(saved.Consumed);
Assert.Equal(gift.GiftDropId, saved.ConsumedGiftDropId);
// And it is a pending box, so the consume the client sends next is what unlocks the item.
var box = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId));
Assert.Equal(gift.GiftDropId, box.GiftDropId);
Assert.Equal(item.AvatarItemDesc, box.AvatarItemDesc);
}
/// <summary>
/// A generate for a reward the selection route already filed as a gift box hands THAT box back.
///
/// This is the shape the client actually collects a quest with: the selection route files the
/// chosen box as a pending gift box, and opening the watch asks for the gift its context names
/// rather than naming the box. The reward itself is completed by that selection, so a lookup that
/// only considers *outstanding* rewards finds nothing - and the collection was answered with a
/// fresh box of tokens while the player's own item sat pending a step away.
/// </summary>
[Fact]
public async Task GenerateGiftHandsBackTheBoxTheSelectionAlreadyFiled()
{
await using var db = NewDb();
var box = RunnerBox();
await SeedCollectedRewardAsync(db, box);
await StorefrontShared.PersistGiftBoxAsync(db, PlayerId, box, notify: false);
var api = new AvatarV2(db);
SetUserAndFormBody(api, $"MFPELEJLJLD={(int)GiftContext.StuntRunner_TheMainEvent_C}");
var gift = Assert.IsType<Gift>(Assert.IsType<OkObjectResult>(await api.GenerateGift()).Value);
Assert.Equal("Runner Harness (Green)", gift.FriendlyName);
Assert.Equal(box.AvatarItemDesc, gift.AvatarItemDesc);
Assert.Equal(CurrencyType.Invalid, gift.CurrencyType);
Assert.Equal(0, gift.Currency);
Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, gift.Context);
}
/// <summary>
/// The reward for an activity context is drawn from that activity's own pool, as an item.
///
/// This is the choice the generate route has to make when nothing is on file for the context the
/// client named - the box of the run it has just finished. It is drawn from the pool that context
/// names and filtered by what the player already owns, and it is never a package of Rec Center
/// tokens: the tokens are what every completed game and quest used to be paid here.
/// </summary>
[Fact]
public async Task BuildRoomRewardSelectsAnUnownedItemFromTheRoomsOwnPool()
{
await using var db = NewDb();
var catalogue = ShippedCatalogueJson();
var box = await GameRewardRedemption.BuildRoomRewardAsync(
db, PlayerId, GiftContext.StuntRunner_TheMainEvent_C, catalogueJson: catalogue);
Assert.NotNull(box);
Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, box!.Context);
Assert.Equal(CurrencyType.Invalid, box.CurrencyType);
Assert.Equal(0, box.Currency);
Assert.True(box.GiftDropId > 0);
Assert.Contains(RroRewardPools.Resolve(GiftContext.StuntRunner_TheMainEvent_C, catalogue),
item => item.AvatarItemDesc == box.AvatarItemDesc);
// And the box the run generated is recorded as handed over, so the reward request that
// follows it cannot pay the same run a second time.
var stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId);
var reward = Assert.Single(stored.GetProgressionJson()!.PendingGameRewards);
Assert.True(reward.Consumed);
Assert.Equal(box.GiftDropId, reward.ConsumedGiftDropId);
Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, reward.GiftContext);
}
/// <summary>
/// A rank the player has taken everything from falls back inside the same quest, then out of it.
///
/// A quest never pays tokens and never reaches into another quest's pool: Stunt Runner S walks
/// down its own ranks, and only once the whole quest is spent does the reward leave it for the
/// ordinary non-exclusive pool.
/// </summary>
[Fact]
public async Task BuildRoomRewardFallsBackInsideTheSameQuestOnly()
{
await using var db = NewDb();
var catalogue = ShippedCatalogueJson();
// The player owns everything Stunt Runner S pays.
var s = RroRewardPools.Resolve(GiftContext.StuntRunner_TheMainEvent_S, catalogue);
Assert.NotEmpty(s);
await OwnEverythingAsync(db, s);
var box = await GameRewardRedemption.BuildRoomRewardAsync(
db, PlayerId, GiftContext.StuntRunner_TheMainEvent_S, catalogueJson: catalogue);
Assert.NotNull(box);
// A lower rank of the same quest - the box keeps the context it was drawn from, and that
// context belongs to Stunt Runner.
Assert.NotEqual(GiftContext.StuntRunner_TheMainEvent_S, box!.Context);
Assert.Equal("StuntRunner", RroRewardPools.RoomForContext(box.Context));
Assert.Contains(RroRewardPools.LowerRanksOf(GiftContext.StuntRunner_TheMainEvent_S), c => c == box.Context);
}
/// <summary>
/// A generate for a finished activity is answered with a gift, never with an error.
///
/// The client uses whatever this route returns as the box the player opens, so a 400 here is a
/// failed generate on the client - "Failed to generate new gift: An error occurred" - and a reward
/// the player never collects. Whether the box holds an item or the ordinary fallback, it is a box.
/// </summary>
[Fact]
public async Task GenerateGiftNeverAnswersAnActivityBoxWithAnError()
{
await using var db = NewDb();
await SeedCollectedRewardAsync(db, RunnerBox());
var api = new AvatarV2(db);
SetUserAndFormBody(api, $"MFPELEJLJLD={(int)GiftContext.StuntRunner_TheMainEvent_C}");
var gift = Assert.IsType<Gift>(Assert.IsType<OkObjectResult>(await api.GenerateGift()).Value);
Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, gift.Context);
Assert.True(gift.GiftDropId > 0);
// It is filed as the player's box, so opening it is what puts the reward in the locker, and
// the hand-over is recorded against it.
var box = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId));
Assert.Equal(gift.GiftDropId, box.GiftDropId);
var stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId);
Assert.Contains(stored.GetProgressionJson()!.PendingGameRewards,
r => r.Consumed && r.ConsumedGiftDropId == gift.GiftDropId);
}
private static async Task OwnEverythingAsync(RecDBContext db, IEnumerable<AvatarItem> items)
{
var owned = items.Select(item => new AvatarItem { AvatarItemDesc = item.AvatarItemDesc }).ToList();
db.Unlocks.Add(new PlayerUnlocks { AccountId = PlayerId, AvatarItems = owned.ToJson() });
await db.SaveChangesAsync();
}
private static string ShippedCatalogueJson() =>
File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "2022-avatar.json"));
private static RecDBContext NewDb() => new(TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static Gift RunnerBox() => new()
{
GiftDropId = 7331,
FriendlyName = "Runner Harness (Green)",
Tooltip = "Runner Harness (Green)",
AvatarItemDesc = "1b3f0c6e-9b21-4c77-9d5a-2f0a53b3d1a4,bb31f2e0-0f2f-4d4a-9c3e-6a0d6b1f4c88",
CurrencyType = CurrencyType.Invalid,
Currency = 0,
Rarity = Rarity.Uncommon,
Context = GiftContext.StuntRunner_TheMainEvent_C
};
private static async Task SeedCollectedRewardAsync(RecDBContext db, Gift box)
{
var reward = new GameReward
{
RewardSelectionId = 5150,
Message = "Stunt Runner: The Main Event",
GiftContext = GiftContext.StuntRunner_TheMainEvent_C,
RewardType = RewardType.PostQuestActivity,
GiftDrop1 = box,
GiftDrop2 = box,
GiftDrop3 = box,
CreatedAt = DateTime.UtcNow.AddMinutes(-5),
Consumed = true,
ConsumedGiftDropId = box.GiftDropId,
ConsumedAt = DateTime.UtcNow.AddMinutes(-1)
};
var json = new ProgressionJson { PendingGameRewards = new List<GameReward> { reward } };
db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Progress = json.ToJson() });
await db.SaveChangesAsync();
}
private static void SetUserAndFormBody(Controller controller, string body)
{
SetRequest(controller, "application/x-www-form-urlencoded", body);
}
private static void SetUserAndJsonBody(Controller controller, string body)
{
SetRequest(controller, "application/json", body);
}
private static void SetRequest(Controller controller, string contentType, string body)
{
var identity = new ClaimsIdentity("TestAuth");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, PlayerId.ToString()));
var context = new DefaultHttpContext { User = new ClaimsPrincipal(identity) };
context.Request.ContentType = contentType;
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
context.Request.ContentLength = context.Request.Body.Length;
controller.ControllerContext = new ControllerContext { HttpContext = context };
}
}