diff --git a/.Lib9c.Tests/Action/ActionEvaluationTest.cs b/.Lib9c.Tests/Action/ActionEvaluationTest.cs index 4b607fbb91..4cb2dbcfa3 100644 --- a/.Lib9c.Tests/Action/ActionEvaluationTest.cs +++ b/.Lib9c.Tests/Action/ActionEvaluationTest.cs @@ -92,6 +92,7 @@ public ActionEvaluationTest() [InlineData(typeof(EndPledge))] [InlineData(typeof(CreatePledge))] [InlineData(typeof(TransferAssets))] + [InlineData(typeof(RuneSummon))] public void Serialize_With_MessagePack(Type actionType) { var action = GetAction(actionType); @@ -462,6 +463,12 @@ private ActionBase GetAction(Type type) { (_signer, 1 * _currency), }), + RuneSummon _ => new RuneSummon + { + AvatarAddress = _sender, + GroupId = 20001, + SummonCount = 10, + }, _ => throw new InvalidCastException(), }; } diff --git a/.Lib9c.Tests/Action/Summon/AuraSummonTest.cs b/.Lib9c.Tests/Action/Summon/AuraSummonTest.cs index bf01380ad0..7f7ebfce4d 100644 --- a/.Lib9c.Tests/Action/Summon/AuraSummonTest.cs +++ b/.Lib9c.Tests/Action/Summon/AuraSummonTest.cs @@ -13,6 +13,7 @@ namespace Lib9c.Tests.Action.Summon using Nekoyume.Action.Exceptions; using Nekoyume.Model.Item; using Nekoyume.Model.State; + using Nekoyume.TableData; using Nekoyume.TableData.Summon; using Xunit; using static SerializeKeys; @@ -176,6 +177,8 @@ public void CumulativeRatio(string version, int groupId) [InlineData("V1", 10001, 11, 800201, 22, 1, new int[] { }, typeof(InvalidSummonCountException))] // 15 recipes [InlineData("V2", 10002, 1, 600201, 1, 5341, new[] { 10650006 }, null)] + // 15 recipes + [InlineData("V3", 20001, 1, 600201, 1, 5341, new int[] { }, typeof(SheetRowNotFoundException))] public void Execute( string version, int groupId, @@ -189,10 +192,14 @@ Type expectedExc { var random = new TestRandom(seed); var state = _initialState; - state = state.SetState( - Addresses.TableSheet.Derive(nameof(SummonSheet)), - version == "V1" ? SummonSheetFixtures.V1.Serialize() : SummonSheetFixtures.V2.Serialize() - ); + var sheet = version switch + { + "V1" => SummonSheetFixtures.V1.Serialize(), + "V2" => SummonSheetFixtures.V2.Serialize(), + "V3" => SummonSheetFixtures.V3.Serialize(), + _ => throw new ArgumentOutOfRangeException(nameof(version), version, null) + }; + state = state.SetState(Addresses.TableSheet.Derive(nameof(SummonSheet)), sheet); if (!(materialId is null)) { diff --git a/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs b/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs new file mode 100644 index 0000000000..6dd8c8e024 --- /dev/null +++ b/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs @@ -0,0 +1,261 @@ +namespace Lib9c.Tests.Action.Summon +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Data; + using System.Linq; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Action.Exceptions; + using Nekoyume.Model.Item; + using Nekoyume.Model.State; + using Nekoyume.TableData; + using Nekoyume.TableData.Summon; + using Xunit; + using static SerializeKeys; + + public class RuneSummonTest + { + private readonly Address _agentAddress; + private readonly Address _avatarAddress; + private readonly AvatarState _avatarState; + private readonly Currency _currency; + private TableSheets _tableSheets; + private IAccount _initialState; + + public RuneSummonTest() + { + var sheets = TableSheetsImporter.ImportSheets(); + _tableSheets = new TableSheets(sheets); + var privateKey = new PrivateKey(); + _agentAddress = privateKey.PublicKey.Address; + var agentState = new AgentState(_agentAddress); + + _avatarAddress = _agentAddress.Derive("avatar"); + _avatarState = new AvatarState( + _avatarAddress, + _agentAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(), + default + ); + + agentState.avatarAddresses.Add(0, _avatarAddress); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + var gold = new GoldCurrencyState(_currency); + + var context = new ActionContext(); + _initialState = new Account(MockState.Empty) + .SetState(_agentAddress, agentState.Serialize()) + .SetState(_avatarAddress, _avatarState.Serialize()) + .SetState(GoldCurrencyState.Address, gold.Serialize()) + .MintAsset(context, GoldCurrencyState.Address, gold.Currency * 100000000000) + .MintAsset(context, _avatarAddress, 100 * Currencies.GetRune("RUNESTONE_FENRIR1")) + .TransferAsset( + context, + Addresses.GoldCurrency, + _agentAddress, + gold.Currency * 1000 + ); + + Assert.Equal( + gold.Currency * 99999999000, + _initialState.GetBalance(Addresses.GoldCurrency, gold.Currency) + ); + Assert.Equal( + gold.Currency * 1000, + _initialState.GetBalance(_agentAddress, gold.Currency) + ); + + foreach (var (key, value) in sheets) + { + _initialState = + _initialState.SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + } + + [Theory] + [InlineData(20001)] + public void CumulativeRatio(int groupId) + { + var sheet = _tableSheets.SummonSheet; + var targetRow = sheet.OrderedList.First(r => r.GroupId == groupId); + + for (var i = 1; i <= SummonSheet.Row.MaxRecipeCount; i++) + { + var sum = 0; + for (var j = 0; j < i; j++) + { + if (j < targetRow.Recipes.Count) + { + sum += targetRow.Recipes[j].Item2; + } + } + + Assert.Equal(sum, targetRow.CumulativeRatio(i)); + } + } + + [Theory] + [ClassData(typeof(ExecuteMemeber))] + public void Execute( + int groupId, + int summonCount, + int? materialId, + int materialCount, + int seed, + Type expectedExc + ) + { + var random = new TestRandom(seed); + var state = _initialState; + state = state.SetState( + Addresses.TableSheet.Derive(nameof(SummonSheet)), + _tableSheets.SummonSheet.Serialize() + ); + + if (!(materialId is null)) + { + var materialSheet = _tableSheets.MaterialItemSheet; + var material = materialSheet.OrderedList.FirstOrDefault(m => m.Id == materialId); + _avatarState.inventory.AddItem( + ItemFactory.CreateItem(material, random), + materialCount * _tableSheets.SummonSheet[groupId].CostMaterialCount + ); + state = state + .SetState(_avatarAddress, _avatarState.SerializeV2()) + .SetState( + _avatarAddress.Derive(LegacyInventoryKey), + _avatarState.inventory.Serialize() + ) + .SetState( + _avatarAddress.Derive(LegacyWorldInformationKey), + _avatarState.worldInformation.Serialize() + ) + .SetState( + _avatarAddress.Derive(LegacyQuestListKey), + _avatarState.questList.Serialize() + ) + ; + } + + var action = new RuneSummon + { + AvatarAddress = _avatarAddress, + GroupId = groupId, + SummonCount = summonCount, + }; + + if (expectedExc == null) + { + // Success + var ctx = new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + BlockIndex = 1, + }; + ctx.SetRandom(random); + var nextState = action.Execute(ctx); + var result = RuneSummon.SimulateSummon( + _tableSheets.RuneSheet, + _tableSheets.SummonSheet[groupId], + summonCount, + new TestRandom(seed) + ); + foreach (var pair in result) + { + var currency = pair.Key; + var prevBalance = state.GetBalance(_avatarAddress, currency); + var balance = nextState.GetBalance(_avatarAddress, currency); + Assert.Equal(currency * pair.Value, balance - prevBalance); + } + + nextState.GetAvatarStateV2(_avatarAddress).inventory + .TryGetItem((int)materialId!, out var resultMaterial); + Assert.Equal(0, resultMaterial?.count ?? 0); + } + else + { + // Failure + Assert.Throws(expectedExc, () => + { + action.Execute(new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + BlockIndex = 1, + RandomSeed = random.Seed, + }); + }); + } + } + + private class ExecuteMemeber : IEnumerable + { + private readonly List _data = new () + { + new object[] + { + 20001, 1, 600201, 1, 1, null, + }, + new object[] + { + 20001, 2, 600201, 2, 54, null, + }, + // Nine plus zero + new object[] + { + 20001, + 9, + 600201, + 9, + 0, + null, + }, + // Ten plus one + new object[] + { + 20001, + 10, + 600201, + 10, + 0, + null, + }, + // fail by invalid group + new object[] + { + 100003, 1, null, 0, 0, typeof(RowNotInTableException), + }, + // fail by not enough material + new object[] + { + 20001, 1, 600201, 0, 0, typeof(NotEnoughMaterialException), + }, + // Fail by exceeding summon limit + new object[] + { + 20001, 11, 600201, 22, 1, typeof(InvalidSummonCountException), + }, + new object[] + { + 10002, 1, 600201, 1, 1, typeof(SheetRowNotFoundException), + }, + }; + + public IEnumerator GetEnumerator() => _data.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _data.GetEnumerator(); + } + } +} diff --git a/.Lib9c.Tests/Fixtures/TableCSV/Summon/SummonSheetFixtures.cs b/.Lib9c.Tests/Fixtures/TableCSV/Summon/SummonSheetFixtures.cs index 0c9338e61d..338025e8b2 100644 --- a/.Lib9c.Tests/Fixtures/TableCSV/Summon/SummonSheetFixtures.cs +++ b/.Lib9c.Tests/Fixtures/TableCSV/Summon/SummonSheetFixtures.cs @@ -11,5 +11,11 @@ public class SummonSheetFixtures @"groupID,cost_material,cost_material_count,cost_ncg,recipe1ID,recipe1ratio,recipe2ID,recipe2ratio,recipe3ID,recipe3ratio,recipe4ID,recipe4ratio,recipe5ID,recipe5ratio,recipe6ID,recipe6ratio,recipe7ID,recipe7ratio,recipe8ID,recipe8ratio,recipe9ID,recipe9ratio,recipe10ID,recipe10ratio,recipe11ID,recipe11ratio,recipe12ID,recipe12ratio,recipe13ID,recipe13ratio,recipe14ID,recipe14ratio,recipe15ID,recipe15ratio 10001,800201,10,0,171,70,172,29,173,1,,,,,,,,,,,,,,,,,,,,,,,, 10002,600201,20,0,174,6500,175,2940,176,510,177,45,178,5,179,6500,180,2940,181,510,182,45,183,5,184,6500,185,2940,186,510,187,45,188,5"; + + public const string V3 = @"groupID,cost_material,cost_material_count,cost_ncg,recipe1ID,recipe1ratio,recipe2ID,recipe2ratio,recipe3ID,recipe3ratio,recipe4ID,recipe4ratio,recipe5ID,recipe5ratio,recipe6ID,recipe6ratio,recipe7ID,recipe7ratio,recipe8ID,recipe8ratio,recipe9ID,recipe9ratio,recipe10ID,recipe10ratio,recipe11ID,recipe11ratio,recipe12ID,recipe12ratio,recipe13ID,recipe13ratio,recipe14ID,recipe14ratio,recipe15ID,recipe15ratio +10001,800201,10,0,171,70,172,29,173,1,,,,,,,,,,,,,,,,,,,,,,,, +10002,600201,20,0,174,6500,175,2940,176,510,177,45,178,5,179,6500,180,2940,181,510,182,45,183,5,184,6500,185,2940,186,510,187,45,188,5 +20001,600201,10,0,10021,20,10022,20,10023,20,10024,20,10025,60,10026,60,10027,60,10028,60,10001,40,10002,100,10003,200,10011,40,10012,100,10013,200,, +"; } } diff --git a/Lib9c.Abstractions/IRuneSummonV1.cs b/Lib9c.Abstractions/IRuneSummonV1.cs new file mode 100644 index 0000000000..2ae332e5be --- /dev/null +++ b/Lib9c.Abstractions/IRuneSummonV1.cs @@ -0,0 +1,12 @@ +using Libplanet.Crypto; + +namespace Lib9c.Abstractions +{ + public interface IRuneSummonV1 + { + Address AvatarAddress { get; } + int GroupId { get; } + + int SummonCount { get; } + } +} diff --git a/Lib9c/Action/RuneSummon.cs b/Lib9c/Action/RuneSummon.cs new file mode 100644 index 0000000000..9255b34fc5 --- /dev/null +++ b/Lib9c/Action/RuneSummon.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Data; +using System.Linq; +using Bencodex.Types; +using Lib9c; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Action.Exceptions; +using Nekoyume.Extensions; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using Nekoyume.TableData.Summon; +using Serilog; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + [ActionType("rune_summon")] + public class RuneSummon : GameAction, IRuneSummonV1 + { + public const string AvatarAddressKey = "aa"; + public Address AvatarAddress; + + public const string GroupIdKey = "gid"; + public int GroupId; + + public const string SummonCountKey = "sc"; + public int SummonCount; + + private const int SummonLimit = 10; + public const int RuneQuantity = 10; + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + var states = context.PreviousState; + var inventoryAddress = AvatarAddress.Derive(LegacyInventoryKey); + + var addressesHex = GetSignerAndOtherAddressesHex(context, AvatarAddress); + var started = DateTimeOffset.UtcNow; + Log.Debug($"{addressesHex} RuneSummon Exec. Started."); + + if (!states.TryGetAvatarStateV2(context.Signer, AvatarAddress, out var avatarState, + out _)) + { + throw new FailedLoadStateException( + $"{addressesHex} Aborted as the avatar state of the signer was failed to load."); + } + + if (SummonCount <= 0 || SummonCount > SummonLimit) + { + throw new InvalidSummonCountException( + $"{addressesHex} Given summonCount {SummonCount} is not valid. Please use between 1 and 10" + ); + } + + // Validate Work + Dictionary sheets = states.GetSheets(sheetTypes: new[] + { + typeof(SummonSheet), + typeof(MaterialItemSheet), + typeof(RuneSheet), + }); + + var summonSheet = sheets.GetSheet(); + var materialSheet = sheets.GetSheet(); + var runeSheet = sheets.GetSheet(); + + var summonRow = summonSheet.OrderedList.FirstOrDefault(row => row.GroupId == GroupId); + if (summonRow is null) + { + throw new RowNotInTableException( + $"{addressesHex} Failed to get {GroupId} in SummonSheet"); + } + + // Use materials + var inventory = avatarState.inventory; + var material = materialSheet.OrderedList.First(m => m.Id == summonRow.CostMaterial); + if (!inventory.RemoveFungibleItem(material.ItemId, context.BlockIndex, + summonRow.CostMaterialCount * SummonCount)) + { + throw new NotEnoughMaterialException( + $"{addressesHex} Aborted as the player has no enough material ({summonRow.CostMaterial} * {summonRow.CostMaterialCount})"); + } + + // Transfer Cost NCG first for fast-fail + if (summonRow.CostNcg > 0L) + { + var arenaSheet = states.GetSheet(); + var arenaData = arenaSheet.GetRoundByBlockIndex(context.BlockIndex); + var feeStoreAddress = + Addresses.GetBlacksmithFeeAddress(arenaData.ChampionshipId, arenaData.Round); + + states = states.TransferAsset( + context, + context.Signer, + feeStoreAddress, + states.GetGoldCurrency() * summonRow.CostNcg * SummonCount + ); + } + + var random = context.GetRandom(); + states = Summon( + context, + AvatarAddress, + runeSheet, + summonRow, + SummonCount, + random, + states + ); + + Log.Debug( + $"{addressesHex} RuneSummon Exec. finished: {DateTimeOffset.UtcNow - started} Elapsed"); + + avatarState.blockIndex = context.BlockIndex; + avatarState.updatedAt = context.BlockIndex; + + // Set states + return states + .SetState(AvatarAddress, avatarState.SerializeV2()) + .SetState(inventoryAddress, avatarState.inventory.Serialize()); + } + + protected override IImmutableDictionary PlainValueInternal => + new Dictionary + { + [AvatarAddressKey] = AvatarAddress.Serialize(), + [GroupIdKey] = (Integer) GroupId, + [SummonCountKey] = (Integer) SummonCount, + }.ToImmutableDictionary(); + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + AvatarAddress = plainValue[AvatarAddressKey].ToAddress(); + GroupId = (Integer) plainValue[GroupIdKey]; + SummonCount = (Integer) plainValue[SummonCountKey]; + } + + Address IRuneSummonV1.AvatarAddress => AvatarAddress; + int IRuneSummonV1.GroupId => GroupId; + int IRuneSummonV1.SummonCount => SummonCount; + + public static IAccount Summon( + IActionContext context, + Address avatarAddress, + RuneSheet runeSheet, + SummonSheet.Row summonRow, + int summonCount, + IRandom random, + IAccount states + ) + { + // Ten plus one + if (summonCount == 10) + { + summonCount += 1; + } + + var result = SimulateSummon(runeSheet, summonRow, summonCount, random); +#pragma warning disable LAA1002 + foreach (var pair in result) +#pragma warning restore LAA1002 + { + states = states.MintAsset(context, avatarAddress, pair.Key * pair.Value); + } + + return states; + } + + public static Dictionary SimulateSummon( + RuneSheet runeSheet, + SummonSheet.Row summonRow, + int summonCount, + IRandom random + ) + { + // Ten plus one + if (summonCount == 10) + { + summonCount += 1; + } + + var result = new Dictionary(); + for (var i = 0; i < summonCount; i++) + { + var recipeId = 0; + var targetRatio = random.Next(1, summonRow.TotalRatio() + 1); + for (var j = 1; j <= SummonSheet.Row.MaxRecipeCount; j++) + { + if (targetRatio <= summonRow.CumulativeRatio(j)) + { + recipeId = summonRow.Recipes[j - 1].Item1; + break; + } + } + + // Validate RecipeId + var runeRow = runeSheet.OrderedList.FirstOrDefault(r => r.Id == recipeId); + if (runeRow is null) + { + throw new SheetRowNotFoundException( + nameof(RuneSheet), + recipeId + ); + } + + var ticker = runeRow.Ticker; + var currency = Currencies.GetRune(ticker); + result.TryAdd(currency, 0); + result[currency] += RuneQuantity; + } + + return result; + } + } +} diff --git a/Lib9c/Model/FungibleItemValue.cs b/Lib9c/Model/FungibleItemValue.cs index 30779794df..0c0398440b 100644 --- a/Lib9c/Model/FungibleItemValue.cs +++ b/Lib9c/Model/FungibleItemValue.cs @@ -9,7 +9,7 @@ public readonly struct FungibleItemValue { public FungibleItemValue(List bencoded) : this( - new HashDigest((Binary)bencoded[0]), + new HashDigest(bencoded[0]), (Integer)bencoded[1] ) { diff --git a/Lib9c/TableCSV/RuneListSheet.csv b/Lib9c/TableCSV/RuneListSheet.csv index 49109f5d79..744533a570 100644 --- a/Lib9c/TableCSV/RuneListSheet.csv +++ b/Lib9c/TableCSV/RuneListSheet.csv @@ -6,4 +6,12 @@ id,_name,grade,rune_type,required_level,use_place 10011,Saehrimnir HIT Rune,2,1,1,7 10012,Saehrimnir DEF Rune,3,1,1,3 10013,Saehrimnir Skill Rune,5,2,1,7 -20001,Golden leaf Rune,5,2,1,7 \ No newline at end of file +20001,Golden leaf Rune,5,2,1,7 +10021,Adventure Stun Rune,5,2,1,1 +10022,Arena Stun Rune,5,2,1,2 +10023,Adventure Vampiric Rune,5,2,1,1 +10024,World Boss Vampiric Rune,5,2,1,4 +10025,Adventure Stamina Rune,3,1,1,1 +10026,Arena Stamina Rune,3,1,1,2 +10027,Adventure Quick Rune,3,1,1,1 +10028,World Boss Quick Rune,3,1,1,4 diff --git a/Lib9c/TableCSV/RuneSheet.csv b/Lib9c/TableCSV/RuneSheet.csv index cbe409846f..0f5a665d96 100644 --- a/Lib9c/TableCSV/RuneSheet.csv +++ b/Lib9c/TableCSV/RuneSheet.csv @@ -6,4 +6,12 @@ id,_name,ticker 10012,Saehrimnir DEF Rune,RUNESTONE_SAEHRIMNIR2 10013,Saehrimnir Skill Rune,RUNESTONE_SAEHRIMNIR3 30001,Adventurer's Rune,RUNE_ADVENTURER -20001,Golden leaf Rune,RUNE_GOLDENLEAF \ No newline at end of file +20001,Golden leaf Rune,RUNE_GOLDENLEAF +10021,Adventure Stun Rune,RUNESTONE_STUN1 +10022,Arena Stun Rune,RUNESTONE_STUN2 +10023,Adventure Vampiric Rune,RUNESTONE_VAMPIRIC1 +10024,World Boss Vampiric Rune,RUNESTONE_VAMPIRIC2 +10025,Adventure Stamina Rune,RUNESTONE_STAMINA1 +10026,Arena Stamina Rune,RUNESTONE_STAMINA2 +10027,Adventure Quick Rune,RUNESTONE_QUICK1 +10028,World Boss Quick Rune,RUNESTONE_QUICK2 diff --git a/Lib9c/TableCSV/Summon/SummonSheet.csv b/Lib9c/TableCSV/Summon/SummonSheet.csv index 382e834fc4..a15f64a265 100644 --- a/Lib9c/TableCSV/Summon/SummonSheet.csv +++ b/Lib9c/TableCSV/Summon/SummonSheet.csv @@ -1,3 +1,4 @@ groupID,cost_material,cost_material_count,cost_ncg,recipe1ID,recipe1ratio,recipe2ID,recipe2ratio,recipe3ID,recipe3ratio,recipe4ID,recipe4ratio,recipe5ID,recipe5ratio,recipe6ID,recipe6ratio,recipe7ID,recipe7ratio,recipe8ID,recipe8ratio,recipe9ID,recipe9ratio,recipe10ID,recipe10ratio,recipe11ID,recipe11ratio,recipe12ID,recipe12ratio,recipe13ID,recipe13ratio,recipe14ID,recipe14ratio,recipe15ID,recipe15ratio 10001,800201,10,0,171,70,172,29,173,1,,,,,,,,,,,,,,,,,,,,,,,, -10002,600201,20,0,174,6500,175,2940,176,510,177,45,178,5,179,6500,180,2940,181,510,182,45,183,5,184,6500,185,2940,186,510,187,45,188,5 \ No newline at end of file +10002,600201,20,0,174,6500,175,2940,176,510,177,45,178,5,179,6500,180,2940,181,510,182,45,183,5,184,6500,185,2940,186,510,187,45,188,5 +20001,600201,10,0,10021,20,10022,20,10023,20,10024,20,10025,60,10026,60,10027,60,10028,60,10001,40,10002,100,10003,200,10011,40,10012,100,10013,200,,