using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using RadicadeServer.API.Avatars.V2;
using RadicadeServer.API.GameRewards.V1;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Moderation;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.RecNet.Storefront;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
///
/// What a completion is filed as, and what the player is handed when they choose.
///
/// Three things were wrong together: the first activity of the day was not treated as a completion
/// at all, so it paid tokens; a game whose own pool could not pay was paid tokens and nothing else;
/// and completing a selection credited the item without telling the client, so the selection could
/// not be replayed and the reward only showed after a relog.
///
public class GameRewardFlowTests
{
private const long PlayerId = 77;
private static RecDBContext NewDb() =>
new(TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
///
/// The same store, with the one console warning the consume route would otherwise raise turned
/// off: it opens a transaction, which the in-memory provider ignores - and reports as an error -
/// rather than supporting. The route's own behaviour is what is under test, not that warning.
///
private static RecDBContext NewTransactionalDb() =>
new(TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options);
private static GameRewardsAPI AsPlayer(RecDBContext db, long playerId = PlayerId) => new(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, playerId.ToString())], "test"))
}
}
};
///
/// Points the player's last visited room at a game room. The activity a completion is filed under
/// is named by the room the player was in, and that is the last room the server recorded a visit
/// to - there is no other way for a test to say which game was played.
///
private static async Task SeedLastVisitedRoomAsync(RecDBContext db, string roomName)
{
var room = new RoomData { Name = roomName };
db.Rooms.Add(room);
await db.SaveChangesAsync();
db.RoomInteractions.Add(new PlayerInteractionDB { PlayerId = PlayerId, RoomId = room.RoomId, Visited = true });
await db.SaveChangesAsync();
}
private static async Task RequestRewardAsync(GameRewardsAPI api, RewardType type, GiftContext? giftContext = null)
{
await RequestOnlyAsync(api, type, giftContext);
return await LatestPendingAsync(api);
}
///
/// Files a reward request and asserts only that it was accepted.
///
/// A quest's reward does not reach /pending: it is handed over as the run's own box with no
/// selection screen, so the tests that check what a quest pays read the player's gifts instead.
///
private static async Task RequestOnlyAsync(GameRewardsAPI api, RewardType type, GiftContext? giftContext = null)
{
var body = Encoding.UTF8.GetBytes(giftContext.HasValue
? $"{{\"rewardType\":{(int)type},\"giftContext\":{(int)giftContext.Value}}}"
: $"{{\"rewardType\":{(int)type}}}");
api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body);
api.ControllerContext.HttpContext.Request.ContentType = "application/json";
var result = await api.RequestReward();
Assert.True(result.Result is OkObjectResult, $"{(result.Result as ObjectResult)?.StatusCode.ToString() ?? "no result"}");
// The route answers the shared success object, not the reward: the client's own wrapper for it
// parses one of those, and the reward reaches the watch through the box the route hands over.
var ok = Assert.IsType(((OkObjectResult)result.Result!).Value);
Assert.True(ok.Success);
}
///
/// Requests a quest completion and returns the box it paid.
///
/// A quest asks the player to choose nothing, so the reward never reaches /pending as a selection:
/// it is filed as the run's own box for the watch to open. These tests read that box, and the empty
/// /pending is asserted here because a quest that left a selection behind would be a quest offering
/// a choice it does not have.
///
private static async Task RequestAndTakeQuestBoxAsync(
GameRewardsAPI api, RecDBContext db, GiftContext claimed, long playerId = PlayerId)
{
await RequestOnlyAsync(api, RewardType.PostGameActivity, claimed);
Assert.Empty(await api.GetPendingGameRewards());
var boxes = await StorefrontShared.GetPendingGiftsAsync(db, playerId);
return Assert.Single(boxes);
}
private static async Task LatestPendingAsync(GameRewardsAPI api)
{
var pending = await api.GetPendingGameRewards();
Assert.NotEmpty(pending);
return pending[^1];
}
private static GameRewardsAPI AsSelecting(RecDBContext db, long selectionId, long giftDropId)
{
var api = AsPlayer(db);
var body = Encoding.UTF8.GetBytes(
$"{{\"rewardSelectionId\":{selectionId},\"giftDropId\":{giftDropId}}}");
api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body);
api.ControllerContext.HttpContext.Request.ContentType = "application/json";
return api;
}
// ---------------------------------------------------------------------------------
// Which type a completion is filed under
// ---------------------------------------------------------------------------------
private static readonly DateTime Monday = new(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void OnlyTheFirstCompletionOfTheDayIsTheFirstActivityOfTheDay()
{
var ledger = RewardDay.NewDay(Monday);
// The client asks for FirstActivityOfDay on every completion. Honouring that claim every time
// is what printed "First Game of the Day" on every reward all day.
Assert.Equal(RewardType.FirstActivityOfDay, RewardDay.CompletionType(ledger, RewardType.FirstActivityOfDay));
Assert.True(RewardDay.TryTakeFirstActivity(ledger));
Assert.Equal(RewardType.PostGameActivity, RewardDay.CompletionType(ledger, RewardType.FirstActivityOfDay));
Assert.False(RewardDay.TryTakeFirstActivity(ledger));
// The first box of the day is its own: it does not eat into the twelve the games pay.
Assert.Equal(0, ledger.ActivityBoxes);
}
[Fact]
public void AFirstCompletionIsTheFirstActivityOfTheDayEvenWhenTheClientCallsItAPostGameReward()
{
var ledger = RewardDay.NewDay(Monday);
Assert.Equal(RewardType.FirstActivityOfDay, RewardDay.CompletionType(ledger, RewardType.PostGameActivity));
RewardDay.TryTakeFirstActivity(ledger);
Assert.Equal(RewardType.PostGameActivity, RewardDay.CompletionType(ledger, RewardType.PostGameActivity));
}
[Fact]
public void AQuestKeepsItsOwnTypeAndStillTakesTheDay()
{
var ledger = RewardDay.NewDay(Monday);
Assert.Equal(RewardType.PostQuestActivity, RewardDay.CompletionType(ledger, RewardType.PostQuestActivity));
RewardDay.TryTakeFirstActivity(ledger);
// The quest was the day's activity, so the game played after it is an ordinary game reward.
Assert.Equal(RewardType.PostGameActivity, RewardDay.CompletionType(ledger, RewardType.FirstActivityOfDay));
}
///
/// The budget: one first-of-the-day box, twelve game boxes, and a daily-challenge box of its own.
///
[Fact]
public void TheDayPaysFourteenBoxesAndNoMore()
{
var ledger = RewardDay.NewDay(Monday);
Assert.True(RewardDay.TryTakeFirstActivity(ledger));
Assert.False(RewardDay.TryTakeFirstActivity(ledger));
for (var i = 0; i < RewardDay.ActivityBoxesPerDay; i++)
Assert.True(RewardDay.TryTakeActivityBox(ledger), $"activity box {i + 1} was refused");
Assert.False(RewardDay.TryTakeActivityBox(ledger));
Assert.True(RewardDay.TryTakeDailyChallengeBox(ledger));
Assert.False(RewardDay.TryTakeDailyChallengeBox(ledger));
Assert.Equal(14, RewardDay.BoxesPerDay);
}
///
/// The ledger is the player's for the day, so a day rolls over - and a second day starts its own
/// count rather than inheriting yesterday's.
///
[Fact]
public void ANewDayStartsANewLedgerAndTheSameDayDoesNot()
{
var json = new ProgressionJson();
var monday = RewardDay.For(json, Monday);
Assert.True(RewardDay.TryTakeFirstActivity(monday));
RewardDay.TryTakeActivityBox(monday);
// The same day is the same ledger, whatever moment it is asked at.
Assert.Same(monday, RewardDay.For(json, Monday.AddHours(6)));
Assert.True(monday.FirstActivityPaid);
Assert.Equal(1, monday.ActivityBoxes);
// A new day is a new count.
var tuesday = RewardDay.For(json, Monday.AddDays(1));
Assert.NotSame(monday, tuesday);
Assert.False(tuesday.FirstActivityPaid);
Assert.Equal(0, tuesday.ActivityBoxes);
}
// ---------------------------------------------------------------------------------
// What a completion is filed as
// ---------------------------------------------------------------------------------
[Fact]
public async Task TheFirstGameOfTheDayIsFiledAsOneAndTheSecondIsNot()
{
await using var db = NewDb();
await SeedLastVisitedRoomAsync(db, "RecRoyaleSolos");
var api = AsPlayer(db);
var first = await RequestRewardAsync(api, RewardType.FirstActivityOfDay);
var second = await RequestRewardAsync(api, RewardType.FirstActivityOfDay);
Assert.Equal(RewardType.FirstActivityOfDay, first.RewardType);
Assert.Equal(RewardType.PostGameActivity, second.RewardType);
}
[Fact]
public async Task ARewardThatIsNotACompletionStillPaysTokens()
{
await using var db = NewDb();
var api = AsPlayer(db);
var reward = await RequestRewardAsync(api, RewardType.Levelup);
Assert.Equal(RewardType.Levelup, reward.RewardType);
Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 },
box => Assert.Equal(CurrencyType.RecCenterTokens, box.CurrencyType));
// The boxes are titled with the amount they hold rather than all being "Rec Center Tokens",
// which made a 100 and a 300 read as the same reward twice.
Assert.Equal("100 Tokens", reward.GiftDrop1.FriendlyName);
Assert.Equal("200 Tokens", reward.GiftDrop2.FriendlyName);
Assert.Equal("300 Tokens", reward.GiftDrop3.FriendlyName);
}
// ---------------------------------------------------------------------------------
// The day's boxes: one first of the day, twelve games, one daily challenge
// ---------------------------------------------------------------------------------
///
/// A completion that pays no box, because the day's are spent. The route still answers success:
/// the client asked for a game reward and the request was accepted, the player is simply not
/// owed another box today.
///
private static async Task RequestRewardWithNoBoxAsync(GameRewardsAPI api, RewardType type)
{
var before = (await api.GetPendingGameRewards()).Count;
var body = Encoding.UTF8.GetBytes($"{{\"rewardType\":{(int)type}}}");
api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body);
api.ControllerContext.HttpContext.Request.ContentType = "application/json";
var result = await api.RequestReward();
Assert.True(Assert.IsType(((OkObjectResult)result.Result!).Value).Success);
Assert.Equal(before, (await api.GetPendingGameRewards()).Count);
}
[Fact]
public async Task TheDayStopsPayingAfterItsFirstBoxAndTwelveGames()
{
await using var db = NewDb();
await SeedLastVisitedRoomAsync(db, "RecRoyaleSolos");
var api = AsPlayer(db);
var first = await RequestRewardAsync(api, RewardType.FirstActivityOfDay);
Assert.Equal(RewardType.FirstActivityOfDay, first.RewardType);
for (var game = 0; game < RewardDay.ActivityBoxesPerDay; game++)
{
var box = await RequestRewardAsync(api, RewardType.FirstActivityOfDay);
Assert.Equal(RewardType.PostGameActivity, box.RewardType);
}
// Thirteen boxes: the first of the day and the twelve games.
Assert.Equal(RewardDay.BoxesPerDay - 1, (await api.GetPendingGameRewards()).Count);
// The fourteenth completion of the day is answered, and pays nothing.
await RequestRewardWithNoBoxAsync(api, RewardType.FirstActivityOfDay);
}
[Fact]
public async Task TheFirstGameOfTheDayIsNotPaidAgainAfterARestart()
{
await using var db = NewDb();
await SeedLastVisitedRoomAsync(db, "RecRoyaleSolos");
// A second API over the same progression is what a server restart looks like from here. The
// day used to be counted in memory, so the first completion after a restart was the first of
// the day again and the player was handed that box a second time.
var beforeRestart = await RequestRewardAsync(AsPlayer(db), RewardType.FirstActivityOfDay);
Assert.Equal(RewardType.FirstActivityOfDay, beforeRestart.RewardType);
Assert.Equal("First Game of the Day", beforeRestart.Message);
var afterRestart = await RequestRewardAsync(AsPlayer(db), RewardType.FirstActivityOfDay);
Assert.Equal(RewardType.PostGameActivity, afterRestart.RewardType);
// And the title is the server's. The client sends "First Game of the Day" as the message on
// every completion, which is why the screen still read that on the day's fifth box.
Assert.Equal("Game Reward", afterRestart.Message);
}
[Fact]
public async Task TheDailyChallengePaysOneBoxADay()
{
await using var db = NewDb();
var api = AsPlayer(db);
var reward = await RequestRewardAsync(api, RewardType.DailyChallengesComplete);
Assert.Equal(RewardType.DailyChallengesComplete, reward.RewardType);
// The daily challenge is a box of its own, filed under the context the client's own enum
// gives it: it is not a game and it names no room.
Assert.Equal(GiftContext.Daily_Challenge_Complete, reward.GiftContext);
// And one a day - the same completion reported again pays nothing.
await RequestRewardWithNoBoxAsync(api, RewardType.DailyChallengesComplete);
}
// ---------------------------------------------------------------------------------
// Which activity the reward is answered from
// ---------------------------------------------------------------------------------
///
/// The reward request arrives after the player has left the game: a finished Stunt Runner run's
/// request came in with the player already back in the Rec Center, so the last visited room was
/// the Rec Center, which pays nothing, and every completion was paid the token package. The
/// context the client claims is what survives the player moving on, and it is what decides the
/// activity when the room they last visited does not pay it.
///
[Fact]
public async Task TheClaimedContextDecidesTheActivityWhenTheLastRoomIsNotOne()
{
await using var db = NewDb();
await SeedLastVisitedRoomAsync(db, "RecCenter");
var api = AsPlayer(db);
var box = await RequestAndTakeQuestBoxAsync(api, db, GiftContext.StuntRunner_TheMainEvent_C);
// Before the claim was consulted the Rec Center named the activity, which is none, and the
// reward was the token package filed under GameRewards_Tokens. A quest asks the player to
// choose nothing, so what the completion pays is the run's own box - not a selection.
Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, box.Context);
Assert.NotEqual(GiftContext.GameRewards_Tokens, box.Context);
}
[Fact]
public async Task ALasertagRewardIsPaidTicketsEvenWhenTheLastRoomIsNotOne()
{
await using var db = NewDb();
await SeedLastVisitedRoomAsync(db, "RecCenter");
var api = AsPlayer(db);
// Tickets are the one reward that needs no catalogue to tell apart from the token package,
// so this is what shows the claim took over the room without depending on the shipped items.
var reward = await RequestRewardAsync(api, RewardType.PostGameActivity, GiftContext.Lasertag);
Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 },
box => Assert.Equal(CurrencyType.LaserTagTickets, box.CurrencyType));
Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 },
box => Assert.Equal("Laser Tag Tickets", box.FriendlyName));
}
[Fact]
public async Task AClaimTheServerDoesNotKnowStillLeavesTheRoomInCharge()
{
await using var db = NewDb();
await SeedLastVisitedRoomAsync(db, "RecCenter");
var api = AsPlayer(db);
var reward = await RequestRewardAsync(api, RewardType.PostGameActivity, GiftContext.Default);
// A claim that names no activity leaves the room in charge, and the room pays nothing, so the
// reward is the token package - filed under the claim it was asked for, which names no game.
Assert.Null(RroRewardPools.RoomForContext(reward.GiftContext));
Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 },
box => Assert.Equal(CurrencyType.RecCenterTokens, box.CurrencyType));
// Each box is titled with its own amount, not all with "Rec Center Tokens".
Assert.Equal("50 Tokens", reward.GiftDrop1.FriendlyName);
Assert.Equal("100 Tokens", reward.GiftDrop2.FriendlyName);
Assert.Equal("150 Tokens", reward.GiftDrop3.FriendlyName);
}
///
/// The quest's item is what the whole collection flow leaves in the player's inventory.
///
/// This is the half that was broken. The selection was right and so was the box it held, and then
/// the item became a box of Rec Center tokens at the moment it was collected, because the collect
/// - a generate naming the reward's context, exactly as the client sends it - asked the server to
/// invent a gift instead of handing over the one the reward was holding. The last assertion is the
/// one that matters: the quest's own descriptor and name, in the player's own unlocks.
///
[Fact]
public async Task AQuestsItemIsWhatTheGenerateAndConsumeFlowLeavesInTheInventory()
{
await using var db = NewTransactionalDb();
var item = new Gift
{
GiftDropId = 6104,
FriendlyName = "Runner Harness (Green)",
Tooltip = "Stunt Runner: The Main Event",
AvatarItemDesc = "d2d3264b-fbf6-40b5-9f10-c85ec737d112,1ba65dd5-9df9-4ff3-be96-f83a7abf67b3",
CurrencyType = CurrencyType.Invalid,
Currency = 0,
Rarity = Rarity.Uncommon,
Context = GiftContext.StuntRunner_TheMainEvent_C
};
var reward = new GameReward
{
RewardSelectionId = 8877,
Message = "Stunt Runner: The Main Event",
GiftContext = GiftContext.StuntRunner_TheMainEvent_C,
RewardType = RewardType.PostQuestActivity,
GiftDrop1 = item,
GiftDrop2 = item,
GiftDrop3 = item,
CreatedAt = DateTime.UtcNow
};
var json = new ProgressionJson { PendingGameRewards = new List { reward } };
db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Progress = json.ToJson() });
await db.SaveChangesAsync();
// The collect the client sends: a generate naming the reward's context, then a consume of the
// box that answered it. Neither names an item; both have to end up naming this one.
var generated = Assert.IsType(Assert.IsType(
await AsAvatarV2(db, $"MFPELEJLJLD={(int)GiftContext.StuntRunner_TheMainEvent_C}&DPAJDIEBNKM=Stunt+Runner")
.GenerateGift()).Value);
Assert.Equal("Runner Harness (Green)", generated.FriendlyName);
Assert.Equal(item.AvatarItemDesc, generated.AvatarItemDesc);
Assert.Equal(CurrencyType.Invalid, generated.CurrencyType);
Assert.Equal(0, generated.Currency);
var consume = AsAvatarV2(db, $"{{\"giftDropId\":{generated.GiftDropId}}}");
Assert.IsType(await consume.ConsumeGiftBody());
var unlocks = await db.Unlocks.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == PlayerId);
var unlocked = Assert.Single(unlocks!.GetAvatarItems(), x => x.AvatarItemDesc == item.AvatarItemDesc);
Assert.Equal("Runner Harness (Green)", unlocked.FriendlyName);
}
private static AvatarV2 AsAvatarV2(RecDBContext db, string body)
{
var api = new AvatarV2(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, PlayerId.ToString())], "test"))
}
}
};
api.ControllerContext.HttpContext.Request.ContentType = "application/json";
api.ControllerContext.HttpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
api.ControllerContext.HttpContext.Request.ContentLength = api.ControllerContext.HttpContext.Request.Body.Length;
return api;
}
///
/// Records a room visit at a point in time, which is what says which game was played and when.
///
private static async Task SeedVisitAsync(RecDBContext db, string roomName, DateTime visitedAt)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.Name == roomName);
if (room == null)
{
room = new RoomData { Name = roomName };
db.Rooms.Add(room);
await db.SaveChangesAsync();
}
db.RoomInteractions.Add(new PlayerInteractionDB
{
PlayerId = PlayerId,
RoomId = room.RoomId,
Visited = true,
LastVisted = visitedAt,
});
await db.SaveChangesAsync();
}
///
/// What the log showed: the reward request for a finished game arrives with the player already
/// back in the Rec Center and a body that names no activity at all. The room they walked into is
/// the newest visit and pays nothing, so the activity has to come from the newest visit that
/// does - the game they were actually placed into.
///
[Fact]
public async Task ACompletionThatNamesNoActivityIsAnsweredFromTheNewestActivityThePlayerWasPlacedInto()
{
await using var db = NewDb();
var now = DateTime.Now;
await SeedVisitAsync(db, "Dodgeball", now.AddMinutes(-5));
await SeedVisitAsync(db, "RecCenter", now.AddSeconds(-5));
var api = AsPlayer(db);
var reward = await RequestRewardAsync(api, RewardType.PostGameActivity);
// Before the room history was consulted the Rec Center named the activity, which is none,
// and the reward was the token package filed under GameRewards_Tokens.
Assert.Equal(GiftContext.Dodgeball, reward.GiftContext);
Assert.NotEqual(GiftContext.GameRewards_Tokens, reward.GiftContext);
}
///
/// The claim outranks the room history: a client that names the activity is believed, so the game
/// played most recently does not answer for the one the completion belongs to.
///
[Fact]
public async Task TheClaimedActivityOutranksTheNewestOneThePlayerWasPlacedInto()
{
await using var db = NewDb();
var now = DateTime.Now;
await SeedVisitAsync(db, "Dodgeball", now.AddMinutes(-30));
await SeedVisitAsync(db, "RecCenter", now.AddSeconds(-5));
var api = AsPlayer(db);
var box = await RequestAndTakeQuestBoxAsync(api, db, GiftContext.StuntRunner_TheMainEvent_C);
// The quest the claim names, not the game played most recently: a Stunt Runner run's box is
// filed under Stunt Runner even though a Dodgeball game is what the room history remembers.
Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, box.Context);
}
///
/// Only recent visits count: a game played hours ago must not answer for a game the server cannot
/// place, or yesterday's activity pays for today's reward.
///
[Fact]
public async Task AStaleActivityDoesNotAnswerForACompletionThatNamesNothing()
{
await using var db = NewDb();
var now = DateTime.Now;
await SeedVisitAsync(db, "Dodgeball", now.AddHours(-8));
await SeedVisitAsync(db, "RecCenter", now.AddSeconds(-5));
var api = AsPlayer(db);
var reward = await RequestRewardAsync(api, RewardType.PostGameActivity);
Assert.Equal(GiftContext.GameRewards_Tokens, reward.GiftContext);
}
// ---------------------------------------------------------------------------------
// What is not a completion
// ---------------------------------------------------------------------------------
[Fact]
public async Task ALevelUpRequestIsNotPaidTheGameCompletedXp()
{
await using var db = NewDb();
// A player of its own, so this account starts with no XP.
var api = AsPlayer(db, 9201);
var reward = await RequestRewardAsync(api, RewardType.Levelup);
Assert.Equal(RewardType.Levelup, reward.RewardType);
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == 9201);
Assert.Equal(0, progression?.XP ?? 0);
}
[Fact]
public async Task ACompletionIsPaidTheGameCompletedXp()
{
await using var db = NewDb();
var api = AsPlayer(db, 9202);
// No room visited, so the reward is the token package - and it is still a completion, so it
// is still paid the game-completed XP.
await RequestRewardAsync(api, RewardType.PostGameActivity);
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == 9202);
Assert.Equal(ProgressionAwardService.XpPerGameCompleted, progression?.XP ?? 0);
}
[Fact]
public async Task ALevelUpPackageIsFiledUnderItsOwnContext()
{
await using var db = NewDb();
var api = AsPlayer(db, 9203);
// A bonus is not a game: the game context the client sent with it does not become the context
// of the boxes, which is what the client paints and groups a gift by.
var reward = await RequestRewardAsync(api, RewardType.Levelup, GiftContext.StuntRunner_TheMainEvent_C);
Assert.Equal(GiftContext.LevelUp, reward.GiftContext);
Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 },
box => Assert.Equal(GiftContext.LevelUp, box.Context));
}
// ---------------------------------------------------------------------------------
// Choosing, and choosing again
// ---------------------------------------------------------------------------------
[Fact]
public async Task ChoosingHandsOverAGiftBoxAndRetryingIsNotAFailure()
{
await using var db = NewDb();
const long selectionId = 280843441;
const long itemDropId = 2045372488;
var item = new Gift
{
GiftDropId = itemDropId,
FriendlyName = "Archer Hat",
AvatarItemDesc = "Archer Hat (Tan)",
Context = GiftContext.Quest_Goblin_C,
CurrencyType = CurrencyType.Invalid
};
db.Progression.Add(new ProgressionData
{
PlayerId = PlayerId,
Progress = new ProgressionJson
{
PendingGameRewards =
[
new GameReward
{
RewardSelectionId = selectionId,
RewardType = RewardType.PostGameActivity,
GiftContext = GiftContext.Quest_Goblin_C,
GiftDrop1 = item,
GiftDrop2 = item,
GiftDrop3 = item
}
]
}.ToJson()
});
await db.SaveChangesAsync();
var api = AsSelecting(db, selectionId, itemDropId);
var first = await api.SelectReward();
Assert.True(Assert.IsType(((OkObjectResult)first.Result!).Value).Success);
// Handed over, not credited here: what the player opens is the box, and the drop keeps the id
// the client already knows because that is the id its own consume call names.
var boxes = await StorefrontShared.GetPendingGiftsAsync(db, PlayerId);
var box = Assert.Single(boxes);
Assert.Equal(itemDropId, box.GiftDropId);
Assert.Equal("Archer Hat (Tan)", box.AvatarItemDesc);
Assert.Empty(await api.GetPendingGameRewards());
// The retry the client makes after a slow answer: the same success, and no second box.
var retryApi = AsSelecting(db, selectionId, itemDropId);
var retry = await retryApi.SelectReward();
Assert.True(Assert.IsType(((OkObjectResult)retry.Result!).Value).Success);
Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId));
var persisted = (await db.Progression.SingleAsync()).GetProgressionJson()!;
var completed = Assert.Single(persisted.PendingGameRewards);
Assert.True(completed.Consumed);
Assert.Equal(itemDropId, completed.ConsumedGiftDropId);
}
[Fact]
public async Task AnUnrecognisedBoxIsStillTheRewardAndSaysSo()
{
await using var db = NewDb();
const long selectionId = 99;
var item = new Gift { GiftDropId = 5, FriendlyName = "Knight Cape", AvatarItemDesc = "Knight Cape (Blue)" };
db.Progression.Add(new ProgressionData
{
PlayerId = PlayerId,
Progress = new ProgressionJson
{
PendingGameRewards = [new GameReward { RewardSelectionId = selectionId, GiftDrop1 = item }]
}.ToJson()
});
await db.SaveChangesAsync();
var api = AsSelecting(db, selectionId, giftDropId: 123456);
var result = await api.SelectReward();
Assert.True(Assert.IsType(((OkObjectResult)result.Result!).Value).Success);
Assert.Equal(5, Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId)).GiftDropId);
}
}