From f6016cc816d2cd4bb07d30d1dc0cbed18d99af28 Mon Sep 17 00:00:00 2001 From: jelq <106406993+jaredstrong89@users.noreply.github.com> Date: Tue, 7 May 2024 13:26:29 -0500 Subject: [PATCH] Campaign skip feature (#777) I have implemented the campaign skip (story skip) functionality. When the "Skip Campaign" button is clicked on the client application (only visible when player has not cleared chapter 10), player data will be adjusted as described in the Dragalia Lost Wiki. - Player level: 60 - Stamina: 999 - Getherwings: 99 - All quest first clear and endeavors completed on Easy mode from chapters 1-10, granting Wyrmite rewards - All quest stories marked as "read" from chapters 1-10 - Story units and dragon rewards added to collection - Tutorials skipped through chapter 10 - Story progression Halidom facilities added to storage with their appropriate levels I also included a fairly detailed unit test, which will check player level, Halidom buildings, etc. [Api Doc](https://dragalia-api-docs.readthedocs.io/en/latest/dragalia/story_skip_skip.html) [Dragalia Lost Wiki](https://dragalialost.wiki/w/Beginner%27s_Guide#The_Story_Skip_Option) This was my first dive into a C# web app, so hopefully my code fits with your coding conventions. --- .../Repositories/IQuestRepository.cs | 1 + .../Features/StorySkip/StorySkipTest.cs | 102 ++++++ .../Features/StorySkip/StorySkipRewards.cs | 58 ++++ .../Features/StorySkip/StorySkipController.cs | 65 ++++ .../Features/StorySkip/StorySkipService.cs | 312 ++++++++++++++++++ .../DragaliaAPI/ServiceConfiguration.cs | 3 + 6 files changed, 541 insertions(+) create mode 100644 DragaliaAPI/DragaliaAPI.Integration.Test/Features/StorySkip/StorySkipTest.cs create mode 100644 DragaliaAPI/DragaliaAPI.Shared/Features/StorySkip/StorySkipRewards.cs create mode 100644 DragaliaAPI/DragaliaAPI/Features/StorySkip/StorySkipController.cs create mode 100644 DragaliaAPI/DragaliaAPI/Features/StorySkip/StorySkipService.cs diff --git a/DragaliaAPI/DragaliaAPI.Database/Repositories/IQuestRepository.cs b/DragaliaAPI/DragaliaAPI.Database/Repositories/IQuestRepository.cs index 6b4b003ab..dc50de093 100644 --- a/DragaliaAPI/DragaliaAPI.Database/Repositories/IQuestRepository.cs +++ b/DragaliaAPI/DragaliaAPI.Database/Repositories/IQuestRepository.cs @@ -6,6 +6,7 @@ public interface IQuestRepository { IQueryable Quests { get; } IQueryable QuestEvents { get; } + IQueryable QuestTreasureList { get; } Task GetQuestDataAsync(int questId); Task GetQuestEventAsync(int questEventId); diff --git a/DragaliaAPI/DragaliaAPI.Integration.Test/Features/StorySkip/StorySkipTest.cs b/DragaliaAPI/DragaliaAPI.Integration.Test/Features/StorySkip/StorySkipTest.cs new file mode 100644 index 000000000..e2b0dfa03 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI.Integration.Test/Features/StorySkip/StorySkipTest.cs @@ -0,0 +1,102 @@ +using DragaliaAPI.Database.Entities; +using DragaliaAPI.Features.StorySkip; +using DragaliaAPI.Shared.Features.StorySkip; +using Microsoft.EntityFrameworkCore; +using static DragaliaAPI.Shared.Features.StorySkip.StorySkipRewards; + +namespace DragaliaAPI.Integration.Test.Features.StorySkip; + +/// +/// Tests +/// +public class StorySkipTest : TestFixture +{ + public StorySkipTest(CustomWebApplicationFactory factory, ITestOutputHelper testOutputHelper) + : base(factory, testOutputHelper) { } + + [Fact] + public async Task StorySkip_CheckStatsAfterSkip() + { + int questId = 100_100_107; + int storyId = 1_001_009; + Dictionary fortConfigs = StorySkipRewards.FortConfigs; + List uniqueFortPlants = new(fortConfigs.Keys); + + await this + .ApiContext.PlayerUserData.Where(x => x.ViewerId == this.ViewerId) + .ExecuteUpdateAsync(u => + u.SetProperty(e => e.Level, 5) + .SetProperty(e => e.Exp, 1) + .SetProperty(e => e.StaminaSingle, 10) + .SetProperty(e => e.StaminaMulti, 10) + ); + + await this + .ApiContext.PlayerQuests.Where(x => x.ViewerId == this.ViewerId && x.QuestId <= questId) + .ExecuteDeleteAsync(); + + await this + .ApiContext.PlayerStoryState.Where(x => + x.ViewerId == this.ViewerId + && x.StoryType == StoryTypes.Quest + && x.StoryId <= storyId + ) + .ExecuteDeleteAsync(); + + await this + .ApiContext.PlayerCharaData.Where(x => + x.ViewerId == this.ViewerId && x.CharaId != Charas.ThePrince + ) + .ExecuteDeleteAsync(); + + await this + .ApiContext.PlayerDragonData.Where(x => x.ViewerId == this.ViewerId) + .ExecuteDeleteAsync(); + + await this + .ApiContext.PlayerFortBuilds.Where(x => x.ViewerId == this.ViewerId) + .ExecuteDeleteAsync(); + + StorySkipSkipResponse data = ( + await this.Client.PostMsgpack("story_skip/skip") + ).Data; + + DbPlayerUserData userData = await this.ApiContext.PlayerUserData.SingleAsync(x => + x.ViewerId == this.ViewerId + ); + + data.Should().BeEquivalentTo(new StorySkipSkipResponse() { ResultState = 1 }); + userData.Level.Should().Be(60); + userData.Exp.Should().Be(69990); + userData.StaminaSingle.Should().Be(999); + userData.StaminaMulti.Should().Be(99); + userData.TutorialFlag.Should().Be(16640603); + userData.TutorialStatus.Should().Be(60999); + this.ApiContext.PlayerQuests.Count(x => x.ViewerId == this.ViewerId && x.QuestId == questId) + .Should() + .Be(1); + this.ApiContext.PlayerStoryState.Count(x => + x.ViewerId == this.ViewerId && x.StoryId == storyId + ) + .Should() + .Be(1); + this.ApiContext.PlayerCharaData.Count(x => x.ViewerId == this.ViewerId).Should().Be(6); + this.ApiContext.PlayerDragonData.Count(x => x.ViewerId == this.ViewerId).Should().Be(5); + + foreach ((FortPlants fortPlant, FortConfig fortConfig) in fortConfigs) + { + List forts = await this + .ApiContext.PlayerFortBuilds.Where(x => + x.ViewerId == this.ViewerId && x.PlantId == fortPlant + ) + .ToListAsync(); + + forts.Count.Should().Be(fortConfig.BuildCount); + + foreach (DbFortBuild fort in forts) + { + fort.Level.Should().Be(fortConfig.Level); + } + } + } +} diff --git a/DragaliaAPI/DragaliaAPI.Shared/Features/StorySkip/StorySkipRewards.cs b/DragaliaAPI/DragaliaAPI.Shared/Features/StorySkip/StorySkipRewards.cs new file mode 100644 index 000000000..164c758e9 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI.Shared/Features/StorySkip/StorySkipRewards.cs @@ -0,0 +1,58 @@ +using DragaliaAPI.Shared.Definitions.Enums; + +namespace DragaliaAPI.Shared.Features.StorySkip; + +public static class StorySkipRewards +{ + public struct FortConfig + { + public int BuildCount { get; } + public int Level { get; } + public int PositionX { get; } + public int PositionZ { get; } + + public FortConfig(int level, int buildCount, int positionX = -1, int positionZ = -1) + { + BuildCount = buildCount; + Level = level; + PositionX = positionX; + PositionZ = positionZ; + } + } + + public static readonly List CharasList = + new() { Charas.Elisanne, Charas.Ranzal, Charas.Cleo, Charas.Luca, Charas.Alex }; + + public static readonly List DragonList = + new() + { + Dragons.Brunhilda, + Dragons.Mercury, + Dragons.Midgardsormr, + Dragons.Jupiter, + Dragons.Zodiark, + }; + + public static readonly Dictionary FortConfigs = + new() + { + [FortPlants.TheHalidom] = new FortConfig(6, 1, 16, 17), + [FortPlants.Smithy] = new FortConfig(6, 1, 21, 3), + [FortPlants.RupieMine] = new FortConfig(15, 4), + [FortPlants.Dragontree] = new FortConfig(15, 1), + [FortPlants.FlameAltar] = new FortConfig(10, 2), + [FortPlants.WaterAltar] = new FortConfig(10, 2), + [FortPlants.WindAltar] = new FortConfig(10, 2), + [FortPlants.LightAltar] = new FortConfig(10, 2), + [FortPlants.ShadowAltar] = new FortConfig(10, 2), + [FortPlants.SwordDojo] = new FortConfig(10, 2), + [FortPlants.BladeDojo] = new FortConfig(10, 2), + [FortPlants.DaggerDojo] = new FortConfig(10, 2), + [FortPlants.LanceDojo] = new FortConfig(10, 2), + [FortPlants.AxeDojo] = new FortConfig(10, 2), + [FortPlants.BowDojo] = new FortConfig(10, 2), + [FortPlants.WandDojo] = new FortConfig(10, 2), + [FortPlants.StaffDojo] = new FortConfig(10, 2), + [FortPlants.ManacasterDojo] = new FortConfig(10, 2) + }; +} diff --git a/DragaliaAPI/DragaliaAPI/Features/StorySkip/StorySkipController.cs b/DragaliaAPI/DragaliaAPI/Features/StorySkip/StorySkipController.cs new file mode 100644 index 000000000..57ba4f44b --- /dev/null +++ b/DragaliaAPI/DragaliaAPI/Features/StorySkip/StorySkipController.cs @@ -0,0 +1,65 @@ +using DragaliaAPI.Controllers; +using DragaliaAPI.Database.Repositories; +using DragaliaAPI.Models.Generated; +using DragaliaAPI.Services; +using DragaliaAPI.Shared.PlayerDetails; +using Microsoft.AspNetCore.Mvc; + +namespace DragaliaAPI.Features.StorySkip; + +[Route("story_skip")] +public class StorySkipController : DragaliaControllerBase +{ + private readonly ILogger logger; + private readonly IPlayerIdentityService playerIdentityService; + private readonly IQuestRepository questRepository; + private readonly StorySkipService storySkipService; + private readonly IUpdateDataService updateDataService; + private readonly IUserDataRepository userDataRepository; + + public StorySkipController( + ILogger logger, + IPlayerIdentityService playerIdentityService, + IQuestRepository questRepository, + StorySkipService storySkipService, + IUpdateDataService updateDataService, + IUserDataRepository userDataRepository + ) + { + this.logger = logger; + this.playerIdentityService = playerIdentityService; + this.questRepository = questRepository; + this.storySkipService = storySkipService; + this.updateDataService = updateDataService; + this.userDataRepository = userDataRepository; + } + + [HttpPost("skip")] + public async Task Read(CancellationToken cancellationToken) + { + string accountId = playerIdentityService.AccountId; + long viewerId = playerIdentityService.ViewerId; + + this.logger.LogDebug("Beginning story skip for player {accountId}.", accountId); + + int wyrmite1 = await storySkipService.ProcessQuestCompletions(viewerId); + this.logger.LogDebug("Wyrmite earned from quests: {wyrmite}", wyrmite1); + + int wyrmite2 = await storySkipService.ProcessStoryCompletions(viewerId); + this.logger.LogDebug("Wyrmite earned from quest stories: {wyrmite}", wyrmite2); + + await storySkipService.UpdateUserData(wyrmite1 + wyrmite2); + + await storySkipService.IncreaseFortLevels(viewerId); + + await storySkipService.RewardCharas(viewerId); + + await storySkipService.RewardDragons(viewerId); + + await updateDataService.SaveChangesAsync(cancellationToken); + + this.logger.LogDebug("Story Skip completed for player {accountId}.", accountId); + + return this.Ok(new StorySkipSkipResponse() { ResultState = 1 }); + } +} diff --git a/DragaliaAPI/DragaliaAPI/Features/StorySkip/StorySkipService.cs b/DragaliaAPI/DragaliaAPI/Features/StorySkip/StorySkipService.cs new file mode 100644 index 000000000..049d5988a --- /dev/null +++ b/DragaliaAPI/DragaliaAPI/Features/StorySkip/StorySkipService.cs @@ -0,0 +1,312 @@ +using System.Collections.Frozen; +using DragaliaAPI.Database; +using DragaliaAPI.Database.Entities; +using DragaliaAPI.Database.Repositories; +using DragaliaAPI.Features.Fort; +using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.Features.StorySkip; +using DragaliaAPI.Shared.MasterAsset; +using DragaliaAPI.Shared.MasterAsset.Models; +using DragaliaAPI.Shared.MasterAsset.Models.Story; +using Microsoft.EntityFrameworkCore; +using static DragaliaAPI.Shared.Features.StorySkip.StorySkipRewards; + +namespace DragaliaAPI.Features.StorySkip; + +public class StorySkipService( + ApiContext apiContext, + IFortRepository fortRepository, + ILogger logger, + IQuestRepository questRepository, + IStoryRepository storyRepository, + IUserDataRepository userDataRepository +) +{ + private static readonly FrozenSet questDatas = MasterAsset + .QuestData.Enumerable.Where(x => + x.Gid < 10011 && x.Id > 100000000 && x.Id.ToString().Substring(6, 1) == "1" + ) + .ToFrozenSet(); + + private static readonly FrozenSet questStories = MasterAsset + .QuestStory.Enumerable.Where(x => x.GroupId is < 10011) + .ToFrozenSet(); + + public async Task IncreaseFortLevels(long viewerId) + { + Dictionary fortConfigs = StorySkipRewards.FortConfigs; + + List userForts = await fortRepository + .Builds.Where(x => x.ViewerId == viewerId) + .ToListAsync(); + + List newUserForts = new(); + + foreach ((FortPlants fortPlant, FortConfig fortConfig) in fortConfigs) + { + List fortsToUpdate = userForts.Where(x => x.PlantId == fortPlant).ToList(); + + foreach (DbFortBuild fortToUpdate in fortsToUpdate) + { + if (fortToUpdate.Level < fortConfig.Level) + { + logger.LogDebug("Updating fort at BuildId {buildId}", fortToUpdate.BuildId); + fortToUpdate.Level = fortConfig.Level; + fortToUpdate.BuildStartDate = DateTimeOffset.UnixEpoch; + fortToUpdate.BuildEndDate = DateTimeOffset.UnixEpoch; + } + } + + for (int x = fortsToUpdate.Count; x < fortConfig.BuildCount; x++) + { + logger.LogDebug("Adding fort {plantId}", fortPlant); + DbFortBuild newUserFort = + new() + { + ViewerId = viewerId, + PlantId = fortPlant, + Level = fortConfig.Level, + PositionX = fortConfig.PositionX, + PositionZ = fortConfig.PositionZ, + BuildStartDate = DateTimeOffset.UnixEpoch, + BuildEndDate = DateTimeOffset.UnixEpoch, + IsNew = true, + LastIncomeDate = DateTimeOffset.UnixEpoch + }; + newUserForts.Add(newUserFort); + } + } + + if (newUserForts.Count > 0) + { + apiContext.PlayerFortBuilds.AddRange(newUserForts); + } + } + + public async Task ProcessQuestCompletions(long viewerId) + { + int wyrmite = 0; + + List userQuests = await questRepository + .Quests.Where(x => x.ViewerId == viewerId) + .ToListAsync(); + + List newUserQuests = new(); + foreach (QuestData questData in questDatas) + { + bool questExists = userQuests.Where(x => x.QuestId == questData.Id).Any(); + if (questExists == false) + { + wyrmite += 25; + DbQuest userQuest = + new() + { + ViewerId = viewerId, + QuestId = questData.Id, + State = 3, + IsMissionClear1 = true, + IsMissionClear2 = true, + IsMissionClear3 = true, + PlayCount = 1, + DailyPlayCount = 1, + WeeklyPlayCount = 1, + IsAppear = true, + BestClearTime = 36000, + LastWeeklyResetTime = DateTimeOffset.UnixEpoch, + LastDailyResetTime = DateTimeOffset.UnixEpoch + }; + newUserQuests.Add(userQuest); + } + else + { + DbQuest userQuest = userQuests.Where(x => x.QuestId == questData.Id).First(); + bool isFirstClear = userQuest.State < 3; + if (isFirstClear) + wyrmite += 10; + if (!userQuest.IsMissionClear1) + { + userQuest.IsMissionClear1 = true; + wyrmite += 5; + } + + if (!userQuest.IsMissionClear2) + { + userQuest.IsMissionClear2 = true; + wyrmite += 5; + } + + if (!userQuest.IsMissionClear3) + { + userQuest.IsMissionClear3 = true; + wyrmite += 5; + } + + if (userQuest.BestClearTime == -1) + { + userQuest.BestClearTime = 36000; + } + + userQuest.PlayCount += 1; + userQuest.DailyPlayCount += 1; + userQuest.WeeklyPlayCount += 1; + userQuest.State = 3; + } + } + + if (newUserQuests.Count > 0) + { + apiContext.PlayerQuests.AddRange(newUserQuests); + } + + return wyrmite; + } + + public async Task ProcessStoryCompletions(long viewerId) + { + int wyrmite = 0; + + List userStories = await storyRepository + .QuestStories.Where(x => x.ViewerId == viewerId) + .ToListAsync(); + + List newUserStories = new(); + foreach (QuestStory questStory in questStories) + { + bool storyExists = userStories.Where(x => x.StoryId == questStory.Id).Any(); + if (storyExists == false) + { + wyrmite += 25; + DbPlayerStoryState userStory = + new() + { + ViewerId = viewerId, + StoryType = StoryTypes.Quest, + StoryId = questStory.Id, + State = StoryState.Read + }; + newUserStories.Add(userStory); + } + } + + if (newUserStories.Count > 0) + { + apiContext.PlayerStoryState.AddRange(newUserStories); + } + + return wyrmite; + } + + public async Task RewardCharas(long viewerId) + { + List charas = StorySkipRewards.CharasList; + List userCharas = await apiContext + .PlayerCharaData.Where(x => x.ViewerId == viewerId && charas.Contains(x.CharaId)) + .ToListAsync(); + + List newUserCharas = new(); + foreach (Charas chara in charas) + { + bool charaExists = userCharas.Where(x => x.CharaId == chara).Any(); + if (charaExists == false) + { + logger.LogDebug("Rewarding character {chara}", chara); + CharaData charaData = MasterAsset.CharaData[chara]; + DbPlayerCharaData newUserChara = + new() + { + ViewerId = viewerId, + CharaId = chara, + Rarity = 4, + Exp = 0, + Level = 1, + HpPlusCount = 0, + AttackPlusCount = 0, + IsNew = true, + Skill1Level = 1, + Skill2Level = 0, + Ability1Level = 1, + Ability2Level = 0, + Ability3Level = 0, + BurstAttackLevel = 0, + ComboBuildupCount = 0, + HpBase = (ushort)charaData.MinHp4, + HpNode = 0, + AttackBase = (ushort)charaData.MinAtk4, + AttackNode = 0, + ExAbilityLevel = 1, + ExAbility2Level = 1, + IsTemporary = false, + ListViewFlag = false + }; + newUserCharas.Add(newUserChara); + } + } + + if (newUserCharas.Count > 0) + { + apiContext.PlayerCharaData.AddRange(newUserCharas); + } + } + + public async Task RewardDragons(long viewerId) + { + List dragons = StorySkipRewards.DragonList; + List userDragons = await apiContext + .PlayerDragonData.Where(x => x.ViewerId == viewerId && dragons.Contains(x.DragonId)) + .ToListAsync(); + + List newUserDragons = new(); + foreach (Dragons dragon in dragons) + { + bool dragonExists = userDragons.Where(x => x.DragonId == dragon).Any(); + if (dragonExists == false) + { + logger.LogDebug("Rewarding dragon {dragon}", dragon); + DbPlayerDragonData newUserDragon = + new() + { + ViewerId = viewerId, + DragonId = dragon, + Exp = 0, + Level = 1, + HpPlusCount = 0, + AttackPlusCount = 0, + LimitBreakCount = 0, + IsLock = false, + IsNew = true, + Skill1Level = 1, + Ability1Level = 1, + Ability2Level = 1 + }; + newUserDragons.Add(newUserDragon); + } + } + + if (newUserDragons.Count > 0) + { + apiContext.PlayerDragonData.AddRange(newUserDragons); + } + } + + public async Task UpdateUserData(int wyrmite) + { + const int MaxLevel = 60; + const int MaxExp = 69990; + DbPlayerUserData data = await userDataRepository.GetUserDataAsync(); + data.TutorialFlag = 16640603; + data.TutorialStatus = 60999; + data.StaminaSingle = 999; + data.StaminaMulti = 99; + data.Crystal += wyrmite; + + if (data.Exp < MaxExp) + { + data.Exp = MaxExp; + } + + if (data.Level < MaxLevel) + { + data.Level = MaxLevel; + } + } +} diff --git a/DragaliaAPI/DragaliaAPI/ServiceConfiguration.cs b/DragaliaAPI/DragaliaAPI/ServiceConfiguration.cs index 09c2fc109..8e6661583 100644 --- a/DragaliaAPI/DragaliaAPI/ServiceConfiguration.cs +++ b/DragaliaAPI/DragaliaAPI/ServiceConfiguration.cs @@ -27,6 +27,7 @@ using DragaliaAPI.Features.Shared.Options; using DragaliaAPI.Features.Shop; using DragaliaAPI.Features.Stamp; +using DragaliaAPI.Features.StorySkip; using DragaliaAPI.Features.Talisman; using DragaliaAPI.Features.TimeAttack; using DragaliaAPI.Features.Trade; @@ -157,6 +158,8 @@ IConfiguration configuration .AddScoped() // Zena feature .AddScoped() + // Story skip feature + .AddScoped() // Maintenance feature .AddScoped();