From 6157f6500998461d3f5c2aa428e2487a82cc681e Mon Sep 17 00:00:00 2001 From: upa-r-upa Date: Mon, 1 Apr 2024 18:01:54 +0900 Subject: [PATCH 1/3] feat: Add exploration dungeon Action & query --- .../Exceptions/InvalidDungeonException.cs | 8 ++ .../Action/ExplorationDungeonAction.cs | 89 +++++++++++++++++++ .../Query/DungeonExplorationQuery.cs | 54 +++++++++++ .../app/Savor22b/GraphTypes/Query/Query.cs | 23 +++-- .../Savor22b/States/DungeonHistoryState.cs | 2 + backend/app/Savor22b/States/RootState.cs | 5 ++ .../app/Savor22b/States/UserDungeonState.cs | 15 ++++ 7 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 backend/app/Savor22b/Action/Exceptions/InvalidDungeonException.cs create mode 100644 backend/app/Savor22b/Action/ExplorationDungeonAction.cs create mode 100644 backend/app/Savor22b/GraphTypes/Query/DungeonExplorationQuery.cs diff --git a/backend/app/Savor22b/Action/Exceptions/InvalidDungeonException.cs b/backend/app/Savor22b/Action/Exceptions/InvalidDungeonException.cs new file mode 100644 index 00000000..b7ed5591 --- /dev/null +++ b/backend/app/Savor22b/Action/Exceptions/InvalidDungeonException.cs @@ -0,0 +1,8 @@ +namespace Savor22b.Action.Exceptions; + +[Serializable] +public class InvalidDungeonException : ActionException +{ + public InvalidDungeonException(string message, int? errorCode = null) + : base(message, "InvalidDungeon", errorCode) { } +} diff --git a/backend/app/Savor22b/Action/ExplorationDungeonAction.cs b/backend/app/Savor22b/Action/ExplorationDungeonAction.cs new file mode 100644 index 00000000..08216883 --- /dev/null +++ b/backend/app/Savor22b/Action/ExplorationDungeonAction.cs @@ -0,0 +1,89 @@ +namespace Savor22b.Action; + +using System; +using System.Collections.Immutable; +using Bencodex.Types; +using Libplanet.Action; +using Libplanet.Headless.Extensions; +using Libplanet.State; +using Savor22b.Action.Util; +using Savor22b.Action.Exceptions; +using Savor22b.States; +using Savor22b.Model; +using Libplanet.Blockchain; + +[ActionType(nameof(ExplorationDungeonAction))] +public class ExplorationDungeonAction : SVRAction +{ + public int DungeonId { get; private set; } + + public ExplorationDungeonAction() { } + + public ExplorationDungeonAction(int dungeonId) + { + DungeonId = dungeonId; + } + + protected override IImmutableDictionary PlainValueInternal => + new Dictionary() + { + [nameof(DungeonId)] = DungeonId.Serialize(), + }.ToImmutableDictionary(); + + protected override void LoadPlainValueInternal(IImmutableDictionary plainValue) + { + DungeonId = plainValue[nameof(DungeonId)].ToInteger(); + } + + private Dungeon ValidateDungeon(int dungeonId) + { + Dungeon? dungeon = CsvDataHelper.GetDungeonById(dungeonId); + + if (dungeon == null) + { + throw new InvalidDungeonException($"Invalid dungeon ID: {dungeonId}"); + } + + return dungeon; + } + + private void ValidateUseDungeonKey(UserDungeonState userDungeonState, long blockIndex) + { + if (!userDungeonState.CanUseDungeonKey(blockIndex)) + { + throw new NotHaveRequiredException("You don't have enough dungeon key"); + } + } + + public override IAccountStateDelta Execute(IActionContext ctx) + { + if (ctx.Rehearsal) + { + return ctx.PreviousStates; + } + + Dungeon dungeon = ValidateDungeon(DungeonId); + + IAccountStateDelta states = ctx.PreviousStates; + + RootState rootState = states.GetState(ctx.Signer) is Dictionary rootStateEncoded + ? new RootState(rootStateEncoded) + : new RootState(); + + UserDungeonState userDungeonState = rootState.UserDungeonState; + + ValidateUseDungeonKey(userDungeonState, ctx.BlockIndex); + + DungeonHistoryState dungeonHistory = new DungeonHistoryState( + ctx.BlockIndex, + DungeonId, + userDungeonState.CalculateDungeonClear(ctx.Random), + dungeon.RewardSeedIdList + ); + + userDungeonState = userDungeonState.AddDungeonHistory(dungeonHistory); + rootState.SetUserDungeonState(userDungeonState); + + return states.SetState(ctx.Signer, rootState.Serialize()); + } +} diff --git a/backend/app/Savor22b/GraphTypes/Query/DungeonExplorationQuery.cs b/backend/app/Savor22b/GraphTypes/Query/DungeonExplorationQuery.cs new file mode 100644 index 00000000..4108e0b0 --- /dev/null +++ b/backend/app/Savor22b/GraphTypes/Query/DungeonExplorationQuery.cs @@ -0,0 +1,54 @@ +namespace Savor22b.GraphTypes.Query; + +using GraphQL; +using GraphQL.Resolvers; +using GraphQL.Types; +using Libplanet.Blockchain; +using Libplanet.Crypto; +using Libplanet.Net; +using Savor22b.Action; + +public class DungeonExplorationQuery : FieldType +{ + public DungeonExplorationQuery(BlockChain blockChain, Swarm swarm) + : base() + { + Name = "createAction_DungeonExploration"; + Type = typeof(NonNullGraphType); + Description = "던전 키를 소모하여 특정 던전의 스테이지를 모두 돌고, 클리어를 시도합니다."; + Arguments = new QueryArguments( + new QueryArgument> + { + Name = "publicKey", + Description = "대상 유저의 40-hex 형태의 address 입니다.", + }, + new QueryArgument> + { + Name = "dungeonId", + Description = "탐험할 던전의 ID 입니다.", + } + ); + Resolver = new FuncFieldResolver(context => + { + try + { + var publicKey = new PublicKey( + Libplanet.ByteUtil.ParseHex(context.GetArgument("publicKey")) + ); + + var action = new ExplorationDungeonAction(context.GetArgument("dungeonId")); + + return new GetUnsignedTransactionHex( + action, + publicKey, + blockChain, + swarm + ).UnsignedTransactionHex; + } + catch (Exception e) + { + throw new ExecutionError(e.Message); + } + }); + } +} diff --git a/backend/app/Savor22b/GraphTypes/Query/Query.cs b/backend/app/Savor22b/GraphTypes/Query/Query.cs index f22001c2..dd33b557 100644 --- a/backend/app/Savor22b/GraphTypes/Query/Query.cs +++ b/backend/app/Savor22b/GraphTypes/Query/Query.cs @@ -528,6 +528,7 @@ swarm is null } ); + // AddField(new DungeonExplorationQuery()); AddField(new CalculateRelocationCostQuery()); AddField(new ShopQuery()); } @@ -536,7 +537,9 @@ private List combineRecipeData() { var foodDict = CsvDataHelper.GetFoodCSVData().ToDictionary(x => x.ID); var ingredientDict = CsvDataHelper.GetIngredientCSVData().ToDictionary(x => x.ID); - var kitchenEquipmentCategoryDict = CsvDataHelper.GetKitchenEquipmentCategoryCSVData().ToDictionary(x => x.ID); + var kitchenEquipmentCategoryDict = CsvDataHelper + .GetKitchenEquipmentCategoryCSVData() + .ToDictionary(x => x.ID); var recipes = new List(); foreach (var recipe in CsvDataHelper.GetRecipeCSVData()) @@ -550,10 +553,20 @@ private List combineRecipeData() var recipeFoodComponents = recipe.FoodIDList .Select(foodID => new RecipeComponent(foodID, foodDict[foodID].Name)) .ToList(); - var requiredKitchenEquipmentCategoryComponents = recipe.RequiredKitchenEquipmentCategoryList - .Select(equipment => new RecipeComponent(equipment, kitchenEquipmentCategoryDict[equipment].Name)) - .ToList(); - var resultFoodComponent = new RecipeComponent(recipe.ResultFoodID, foodDict[recipe.ResultFoodID].Name); + var requiredKitchenEquipmentCategoryComponents = + recipe.RequiredKitchenEquipmentCategoryList + .Select( + equipment => + new RecipeComponent( + equipment, + kitchenEquipmentCategoryDict[equipment].Name + ) + ) + .ToList(); + var resultFoodComponent = new RecipeComponent( + recipe.ResultFoodID, + foodDict[recipe.ResultFoodID].Name + ); recipes.Add( new RecipeResponse( diff --git a/backend/app/Savor22b/States/DungeonHistoryState.cs b/backend/app/Savor22b/States/DungeonHistoryState.cs index 2e481b29..8fcf028c 100644 --- a/backend/app/Savor22b/States/DungeonHistoryState.cs +++ b/backend/app/Savor22b/States/DungeonHistoryState.cs @@ -9,6 +9,8 @@ public class DungeonHistoryState : State public long BlockIndex { get; private set; } public int DungeonId { get; private set; } public int DungeonClearStatus { get; private set; } + + // 0: Not Cleared, 1: Cleared public ImmutableList DungeonClearRewardSeedIdList { get; private set; } public DungeonHistoryState( diff --git a/backend/app/Savor22b/States/RootState.cs b/backend/app/Savor22b/States/RootState.cs index c0597a9d..a916f04d 100644 --- a/backend/app/Savor22b/States/RootState.cs +++ b/backend/app/Savor22b/States/RootState.cs @@ -94,6 +94,11 @@ public void SetRelocationState(RelocationState relocationState) RelocationState = relocationState; } + public void SetUserDungeonState(UserDungeonState userDungeonState) + { + UserDungeonState = userDungeonState; + } + public IValue Serialize() { var pairs = new[] diff --git a/backend/app/Savor22b/States/UserDungeonState.cs b/backend/app/Savor22b/States/UserDungeonState.cs index 9c68e4ea..936fdc8e 100644 --- a/backend/app/Savor22b/States/UserDungeonState.cs +++ b/backend/app/Savor22b/States/UserDungeonState.cs @@ -66,6 +66,16 @@ public int GetDungeonKeyCount(long blockIndex) return MaxDungeonKey - GetCurrentDungeonHistories(blockIndex).Count; } + public bool CanUseDungeonKey(long blockIndex) + { + return GetDungeonKeyCount(blockIndex) > 0; + } + + public int CalculateDungeonClear(Libplanet.Action.IRandom random) + { + return random.Next(0, 2) == 1 ? 1 : 0; + } + public ImmutableList GetCurrentDungeonHistories(long blockIndex) { var lowerBoundIndex = blockIndex - (MaxDungeonKey * DungeonKeyChargeIntervalBlock); @@ -87,4 +97,9 @@ public ImmutableList GetCurrentDungeonHistories(long blockI return result.ToImmutableList(); } + + public UserDungeonState AddDungeonHistory(DungeonHistoryState dungeonHistory) + { + return new UserDungeonState(DungeonHistories.Add(dungeonHistory), DungeonConquestHistories); + } } From 60f1a2ecfa7b82dcff41f37fa7c0d0e3eb296d7b Mon Sep 17 00:00:00 2001 From: upa-r-upa Date: Mon, 1 Apr 2024 18:02:22 +0900 Subject: [PATCH 2/3] test: Add exploration dungeon action tests --- .../Action/ExplorationDungeonActionTests.cs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 backend/app/Savor22b.Tests/Action/ExplorationDungeonActionTests.cs diff --git a/backend/app/Savor22b.Tests/Action/ExplorationDungeonActionTests.cs b/backend/app/Savor22b.Tests/Action/ExplorationDungeonActionTests.cs new file mode 100644 index 00000000..ea87959e --- /dev/null +++ b/backend/app/Savor22b.Tests/Action/ExplorationDungeonActionTests.cs @@ -0,0 +1,170 @@ +namespace Savor22b.Tests.Action; + +using System; +using System.Collections.Immutable; +using Libplanet.State; +using Savor22b.Action; +using Savor22b.Action.Exceptions; +using Savor22b.States; +using Xunit; + +public class ExplorationDungeonActionTests : ActionTests +{ + public static int TestDungeonId = 0; + + public ExplorationDungeonActionTests() { } + + [Fact] + public void Execute_Success_Normal() + { + IAccountStateDelta beforeState = new DummyState(); + var state = new RootState(); + beforeState = beforeState.SetState(SignerAddress(), state.Serialize()); + + ExplorationDungeonAction action = new ExplorationDungeonAction(TestDungeonId); + + IAccountStateDelta afterState = action.Execute( + new DummyActionContext + { + PreviousStates = beforeState, + Signer = SignerAddress(), + Random = random, + Rehearsal = false, + BlockIndex = 1, + } + ); + + Bencodex.Types.IValue? afterRootStateEncoded = afterState.GetState(SignerAddress()); + RootState afterRootState = afterRootStateEncoded is Bencodex.Types.Dictionary bdict + ? new RootState(bdict) + : throw new Exception(); + UserDungeonState userDungeonState = afterRootState.UserDungeonState; + + Assert.True(userDungeonState.DungeonHistories.Count == 1); + Assert.Equal(TestDungeonId, userDungeonState.DungeonHistories[0].DungeonId); + Assert.Equal(UserDungeonState.MaxDungeonKey - 1, userDungeonState.GetDungeonKeyCount(2)); + Assert.True( + CsvDataHelper + .GetDungeonById(TestDungeonId)! + .RewardSeedIdList.SequenceEqual( + userDungeonState.DungeonHistories[0].DungeonClearRewardSeedIdList + ) + ); + } + + [Fact] + public void Execute_Success_AlreadyUsedKey() + { + var currentBlockIndex = + UserDungeonState.DungeonKeyChargeIntervalBlock * UserDungeonState.MaxDungeonKey + 4; + var dungeonHistories = ImmutableList.Create( + new DungeonHistoryState(1, TestDungeonId, 0, ImmutableList.Empty), + new DungeonHistoryState(2, TestDungeonId, 0, ImmutableList.Empty), + new DungeonHistoryState(3, TestDungeonId, 0, ImmutableList.Empty), + new DungeonHistoryState(4, TestDungeonId, 0, ImmutableList.Empty), + new DungeonHistoryState(5, TestDungeonId, 0, ImmutableList.Empty) + ); + + IAccountStateDelta beforeState = new DummyState(); + var state = new RootState( + new InventoryState(), + new UserDungeonState(dungeonHistories, ImmutableList.Empty) + ); + beforeState = beforeState.SetState(SignerAddress(), state.Serialize()); + + ExplorationDungeonAction action = new ExplorationDungeonAction(TestDungeonId); + + IAccountStateDelta afterState = action.Execute( + new DummyActionContext + { + PreviousStates = beforeState, + Signer = SignerAddress(), + Random = random, + Rehearsal = false, + BlockIndex = currentBlockIndex, + } + ); + + Bencodex.Types.IValue? afterRootStateEncoded = afterState.GetState(SignerAddress()); + RootState afterRootState = afterRootStateEncoded is Bencodex.Types.Dictionary bdict + ? new RootState(bdict) + : throw new Exception(); + UserDungeonState userDungeonState = afterRootState.UserDungeonState; + + Assert.True(userDungeonState.DungeonHistories.Count == 6); + Assert.Equal(TestDungeonId, userDungeonState.DungeonHistories[5].DungeonId); + Assert.Equal( + UserDungeonState.MaxDungeonKey - 2, + userDungeonState.GetDungeonKeyCount(currentBlockIndex) + ); + Assert.True( + CsvDataHelper + .GetDungeonById(TestDungeonId)! + .RewardSeedIdList.SequenceEqual( + userDungeonState.DungeonHistories[5].DungeonClearRewardSeedIdList + ) + ); + } + + [Fact] + public void Execute_Fail_NotHaveRequiredDungeonKey() + { + var currentBlockIndex = + UserDungeonState.DungeonKeyChargeIntervalBlock * UserDungeonState.MaxDungeonKey + 3; + var dungeonHistories = ImmutableList.Create( + new DungeonHistoryState(1, TestDungeonId, 0, ImmutableList.Empty), + new DungeonHistoryState(4, TestDungeonId, 0, ImmutableList.Empty), + new DungeonHistoryState(5, TestDungeonId, 0, ImmutableList.Empty), + new DungeonHistoryState(6, TestDungeonId, 0, ImmutableList.Empty), + new DungeonHistoryState(7, TestDungeonId, 0, ImmutableList.Empty), + new DungeonHistoryState(8, TestDungeonId, 0, ImmutableList.Empty) + ); + + IAccountStateDelta beforeState = new DummyState(); + var state = new RootState( + new InventoryState(), + new UserDungeonState(dungeonHistories, ImmutableList.Empty) + ); + beforeState = beforeState.SetState(SignerAddress(), state.Serialize()); + + ExplorationDungeonAction action = new ExplorationDungeonAction(TestDungeonId); + + Assert.Throws( + () => + action.Execute( + new DummyActionContext + { + PreviousStates = beforeState, + Signer = SignerAddress(), + Random = random, + Rehearsal = false, + BlockIndex = currentBlockIndex, + } + ) + ); + } + + [Fact] + public void Execute_Fail_InvalidDungeonId() + { + IAccountStateDelta beforeState = new DummyState(); + var state = new RootState(); + beforeState = beforeState.SetState(SignerAddress(), state.Serialize()); + + ExplorationDungeonAction action = new ExplorationDungeonAction(-1); + + Assert.Throws( + () => + action.Execute( + new DummyActionContext + { + PreviousStates = beforeState, + Signer = SignerAddress(), + Random = random, + Rehearsal = false, + BlockIndex = 1, + } + ) + ); + } +} From e458eff9726ac7c7d07dd73f2f0c2e16fba94b21 Mon Sep 17 00:00:00 2001 From: upa-r-upa Date: Mon, 1 Apr 2024 18:06:20 +0900 Subject: [PATCH 3/3] feat: Add DungeonExplorationQuery on query field --- backend/app/Savor22b/GraphTypes/Query/Query.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/Savor22b/GraphTypes/Query/Query.cs b/backend/app/Savor22b/GraphTypes/Query/Query.cs index dd33b557..fc6d2213 100644 --- a/backend/app/Savor22b/GraphTypes/Query/Query.cs +++ b/backend/app/Savor22b/GraphTypes/Query/Query.cs @@ -528,7 +528,7 @@ swarm is null } ); - // AddField(new DungeonExplorationQuery()); + AddField(new DungeonExplorationQuery(blockChain, swarm)); AddField(new CalculateRelocationCostQuery()); AddField(new ShopQuery()); }