diff --git a/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs b/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs new file mode 100644 index 0000000000..c12c3faeb1 --- /dev/null +++ b/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs @@ -0,0 +1,259 @@ +namespace Lib9c.Tests.Action.Summon +{ + using System; + using System.Collections.Generic; + using System.Data; + using System.Linq; + using Lib9c.Tests.Fixtures.TableCSV.Summon; + 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.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) + .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)] + [InlineData(20002)] + 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] + // success first group + [InlineData(20001, 1, 800201, 1, 1, new[] { 10610000 }, null)] + [InlineData(20001, 2, 800201, 2, 54, new[] { 10620000, 10630000 }, null)] + // success second group + [InlineData(20002, 1, 600201, 1, 1, new[] { 10620001 }, null)] + [InlineData(20002, 2, 600201, 2, 4, new[] { 10620001, 10630001 }, null)] + // Nine plus zero + [InlineData( + 20001, + 9, + 800201, + 9, + 0, + new[] { 10610000, 10610000, 10610000, 10610000, 10610000, 10610000, 10620000, 10620000, 10620000 }, + null + )] + [InlineData( + 20002, + 9, + 600201, + 9, + 0, + new[] { 10620001, 10620001, 10620001, 10620001, 10620001, 10630001, 10630001, 10630001, 10630001 }, + null + )] + // Ten plus one + [InlineData( + 20001, + 10, + 800201, + 10, + 0, + new[] { 10610000, 10610000, 10610000, 10610000, 10610000, 10610000, 10610000, 10610000, 10620000, 10620000, 10620000 }, + null + )] + [InlineData( + 20002, + 10, + 600201, + 10, + 0, + new[] { 10620001, 10620001, 10620001, 10620001, 10620001, 10620001, 10630001, 10620001, 10630001, 10630001, 10630001 }, + null + )] + // fail by invalid group + [InlineData(100003, 1, null, 0, 0, new int[] { }, typeof(RowNotInTableException))] + // fail by not enough material + [InlineData(20001, 1, 800201, 0, 0, new int[] { }, typeof(NotEnoughMaterialException))] + [InlineData(20001, 2, 800201, 0, 0, new int[] { }, typeof(NotEnoughMaterialException))] + // Fail by exceeding summon limit + [InlineData(20001, 11, 800201, 22, 1, new int[] { }, typeof(InvalidSummonCountException))] + // 15 recipes + [InlineData(20002, 1, 600201, 1, 5341, new[] { 10650006 }, null)] + public void Execute( + int groupId, + int summonCount, + int? materialId, + int materialCount, + int seed, + int[] expectedEquipmentId, + 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 equipments = nextState.GetAvatarStateV2(_avatarAddress).inventory.Equipments + .ToList(); + Assert.Equal(expectedEquipmentId.Length, equipments.Count); + + var checkedEquipments = new List(); + foreach (var equipmentId in expectedEquipmentId) + { + var resultEquipment = equipments.First(e => + e.Id == equipmentId && !checkedEquipments.Contains(e.ItemId) + ); + + checkedEquipments.Add(resultEquipment.ItemId); + Assert.NotNull(resultEquipment); + Assert.Equal(1, resultEquipment.RequiredBlockIndex); + Assert.True(resultEquipment.optionCountFromCombination > 0); + } + + 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, + }); + }); + } + } + } +} diff --git a/Lib9c.Abstractions/IRuneSummonV1.cs b/Lib9c.Abstractions/IRuneSummonV1.cs new file mode 100644 index 0000000000..51646c8794 --- /dev/null +++ b/Lib9c.Abstractions/IRuneSummonV1.cs @@ -0,0 +1,11 @@ +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..e6935cdb54 --- /dev/null +++ b/Lib9c/Action/RuneSummon.cs @@ -0,0 +1,200 @@ +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 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 = 100; + + 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.TryGetAgentAvatarStatesV2(context.Signer, AvatarAddress, out var agentState, + 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 = SimulateSummon( + context, + addressesHex, + 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()) + .SetState(context.Signer, agentState.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 SimulateSummon( + IActionContext context, + string addressesHex, + Address avatarAddress, + RuneSheet runeSheet, + SummonSheet.Row summonRow, + int summonCount, + IRandom random, + IAccount states + ) + { + // Ten plus one + if (summonCount == 10) + { + summonCount += 1; + } + + 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( + addressesHex, + nameof(RuneSheet), + recipeId + ); + } + + var ticker = runeRow.Ticker; + var currency = Currencies.GetRune(ticker); + states = states.MintAsset(context, avatarAddress, RuneQuantity * currency); + } + + return states; + } + } +} diff --git a/Lib9c/TableCSV/Summon/SummonSheet.csv b/Lib9c/TableCSV/Summon/SummonSheet.csv index 382e834fc4..eeebd0d0a4 100644 --- a/Lib9c/TableCSV/Summon/SummonSheet.csv +++ b/Lib9c/TableCSV/Summon/SummonSheet.csv @@ -1,3 +1,5 @@ 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,800201,10,0,10001,70,10002,29,10003,1,,,,,,,,,,,,,,,,,,,,,,,, +20002,600201,20,0,10001,30,10002,10,10003,10,10011,10,10012,10,10013,10,30001,10,20001,10,,,,,,,,,,,,,,