mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
417 lines
19 KiB
C#
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 };
|
|
}
|
|
}
|