diff --git a/.Lib9c.Tests/Action/ItemEnhancement12Test.cs b/.Lib9c.Tests/Action/ItemEnhancement12Test.cs new file mode 100644 index 0000000000..aa3dddb780 --- /dev/null +++ b/.Lib9c.Tests/Action/ItemEnhancement12Test.cs @@ -0,0 +1,340 @@ +namespace Lib9c.Tests.Action +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using Bencodex.Types; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Extensions; + using Nekoyume.Model.Item; + using Nekoyume.Model.Mail; + using Nekoyume.Model.State; + using Xunit; + using static SerializeKeys; + + public class ItemEnhancement12Test + { + private readonly TableSheets _tableSheets; + private readonly Address _agentAddress; + private readonly Address _avatarAddress; + private readonly AvatarState _avatarState; + private readonly Currency _currency; + private IAccountStateDelta _initialState; + + public ItemEnhancement12Test() + { + var sheets = TableSheetsImporter.ImportSheets(); + _tableSheets = new TableSheets(sheets); + var privateKey = new PrivateKey(); + _agentAddress = privateKey.PublicKey.ToAddress(); + 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 slotAddress = _avatarAddress.Derive(string.Format( + CultureInfo.InvariantCulture, + CombinationSlotState.DeriveFormat, + 0 + )); + + var context = new ActionContext(); + _initialState = new MockStateDelta() + .SetState(_agentAddress, agentState.Serialize()) + .SetState(_avatarAddress, _avatarState.Serialize()) + .SetState(slotAddress, new CombinationSlotState(slotAddress, 0).Serialize()) + .SetState(GoldCurrencyState.Address, gold.Serialize()) + .MintAsset(context, GoldCurrencyState.Address, gold.Currency * 100_000_000_000) + .TransferAsset( + context, + Addresses.GoldCurrency, + _agentAddress, + gold.Currency * 3_000_000 + ); + + Assert.Equal( + gold.Currency * 99_997_000_000, + _initialState.GetBalance(Addresses.GoldCurrency, gold.Currency) + ); + Assert.Equal( + gold.Currency * 3_000_000, + _initialState.GetBalance(_agentAddress, gold.Currency) + ); + + foreach (var (key, value) in sheets) + { + _initialState = + _initialState.SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + } + + [Theory] + // from 0 to 0 using one level 0 material + [InlineData(0, false, 0, 0, false, 1)] + [InlineData(0, false, 0, 0, true, 1)] + [InlineData(0, true, 0, 0, false, 1)] + [InlineData(0, true, 0, 0, true, 1)] + // from 0 to 1 using two level 0 material + [InlineData(0, false, 1, 0, false, 3)] + [InlineData(0, false, 1, 0, true, 3)] + [InlineData(0, true, 1, 0, false, 3)] + [InlineData(0, true, 1, 0, true, 3)] + // from 0 to N using multiple level 0 materials + [InlineData(0, false, 2, 0, false, 7)] + [InlineData(0, false, 4, 0, false, 31)] + [InlineData(0, false, 2, 0, true, 7)] + [InlineData(0, false, 4, 0, true, 31)] + [InlineData(0, true, 2, 0, false, 7)] + [InlineData(0, true, 4, 0, false, 31)] + [InlineData(0, true, 2, 0, true, 7)] + [InlineData(0, true, 4, 0, true, 31)] + // from K to K with material(s). Check requiredBlock == 0 + [InlineData(10, false, 10, 0, false, 1)] + [InlineData(10, false, 10, 0, true, 1)] + [InlineData(10, true, 10, 0, false, 1)] + [InlineData(10, true, 10, 0, true, 1)] + // from K to N using one level X material + [InlineData(5, false, 6, 6, false, 1)] + [InlineData(5, false, 6, 6, true, 1)] + [InlineData(5, true, 6, 6, false, 1)] + [InlineData(5, true, 6, 6, true, 1)] + // from K to N using multiple materials + [InlineData(5, false, 7, 4, false, 6)] + [InlineData(5, false, 9, 7, false, 5)] + [InlineData(5, false, 7, 4, true, 6)] + [InlineData(5, false, 9, 7, true, 5)] + [InlineData(5, true, 7, 4, false, 6)] + [InlineData(5, true, 9, 7, false, 5)] + [InlineData(5, true, 7, 4, true, 6)] + [InlineData(5, true, 9, 7, true, 5)] + // from 20 to 21 (just to reach level 21 exp) + [InlineData(20, false, 21, 20, false, 1)] + [InlineData(20, false, 21, 20, true, 1)] + [InlineData(20, true, 21, 20, false, 1)] + [InlineData(20, true, 21, 20, true, 1)] + // from 20 to 21 (over level 21) + [InlineData(20, false, 21, 20, false, 2)] + [InlineData(20, false, 21, 20, true, 2)] + [InlineData(20, true, 21, 20, false, 2)] + [InlineData(20, true, 21, 20, true, 2)] + // from 21 to 21 (no level up) + [InlineData(21, false, 21, 1, false, 1)] + [InlineData(21, false, 21, 21, false, 1)] + [InlineData(21, false, 21, 1, true, 1)] + [InlineData(21, false, 21, 21, true, 1)] + [InlineData(21, true, 21, 1, false, 1)] + [InlineData(21, true, 21, 21, false, 1)] + [InlineData(21, true, 21, 1, true, 1)] + [InlineData(21, true, 21, 21, true, 1)] + // Test: change of exp, change of level, required block, NCG price + public void Execute( + int startLevel, + bool oldStart, + int expectedLevel, + int materialLevel, + bool oldMaterial, + int materialCount) + { + var row = _tableSheets.EquipmentItemSheet.Values.First(r => r.Id == 10110000); + var equipment = (Equipment)ItemFactory.CreateItemUsable(row, default, 0, startLevel); + if (startLevel == 0) + { + equipment.Exp = (long)row.Exp!; + } + else + { + equipment.Exp = _tableSheets.EnhancementCostSheetV3.OrderedList.First(r => + r.ItemSubType == equipment.ItemSubType && r.Grade == equipment.Grade && + r.Level == equipment.level).Exp; + } + + var startExp = equipment.Exp; + if (oldStart) + { + equipment.Exp = 0L; + } + + _avatarState.inventory.AddItem(equipment, count: 1); + + var expectedTargetRow = _tableSheets.EnhancementCostSheetV3.OrderedList.FirstOrDefault( + r => + r.Grade == equipment.Grade && r.ItemSubType == equipment.ItemSubType && + r.Level == expectedLevel); + var startRow = _tableSheets.EnhancementCostSheetV3.OrderedList.FirstOrDefault(r => + r.Grade == equipment.Grade && r.ItemSubType == equipment.ItemSubType && + r.Level == startLevel); + var expectedCost = (expectedTargetRow?.Cost ?? 0) - (startRow?.Cost ?? 0); + var expectedBlockIndex = + (expectedTargetRow?.RequiredBlockIndex ?? 0) - (startRow?.RequiredBlockIndex ?? 0); + + var expectedExpIncrement = 0L; + var materialIds = new List(); + for (var i = 0; i < materialCount; i++) + { + var materialId = Guid.NewGuid(); + materialIds.Add(materialId); + var material = + (Equipment)ItemFactory.CreateItemUsable(row, materialId, 0, materialLevel); + if (materialLevel == 0) + { + material.Exp = (long)row.Exp!; + } + else + { + material.Exp = _tableSheets.EnhancementCostSheetV3.OrderedList.First(r => + r.ItemSubType == material.ItemSubType && r.Grade == material.Grade && + r.Level == material.level).Exp; + } + + expectedExpIncrement += material.Exp; + if (oldMaterial) + { + material.Exp = 0L; + } + + _avatarState.inventory.AddItem(material, count: 1); + } + + var result = new CombinationConsumable5.ResultModel() + { + id = default, + gold = 0, + actionPoint = 0, + recipeId = 1, + materials = new Dictionary(), + itemUsable = equipment, + }; + var preItemUsable = new Equipment((Dictionary)equipment.Serialize()); + + for (var i = 0; i < 100; i++) + { + var mail = new CombinationMail(result, i, default, 0); + _avatarState.Update(mail); + } + + _avatarState.worldInformation.ClearStage( + 1, + 1, + 1, + _tableSheets.WorldSheet, + _tableSheets.WorldUnlockSheet + ); + + var slotAddress = + _avatarAddress.Derive(string.Format( + CultureInfo.InvariantCulture, + CombinationSlotState.DeriveFormat, + 0 + )); + + Assert.Equal(startLevel, equipment.level); + + _initialState = _initialState + .SetState( + _avatarAddress.Derive(LegacyInventoryKey), + _avatarState.inventory.Serialize() + ) + .SetState( + _avatarAddress.Derive(LegacyWorldInformationKey), + _avatarState.worldInformation.Serialize() + ) + .SetState( + _avatarAddress.Derive(LegacyQuestListKey), + _avatarState.questList.Serialize() + ) + .SetState(_avatarAddress, _avatarState.SerializeV2()); + + var action = new ItemEnhancement12() + { + itemId = default, + materialIds = materialIds, + avatarAddress = _avatarAddress, + slotIndex = 0, + }; + + var nextState = action.Execute(new ActionContext() + { + PreviousState = _initialState, + Signer = _agentAddress, + BlockIndex = 1, + Random = new TestRandom(), + }); + + var slotState = nextState.GetCombinationSlotState(_avatarAddress, 0); + var resultEquipment = (Equipment)slotState.Result.itemUsable; + var nextAvatarState = nextState.GetAvatarState(_avatarAddress); + Assert.Equal(default, resultEquipment.ItemId); + Assert.Equal(expectedLevel, resultEquipment.level); + Assert.Equal(startExp + expectedExpIncrement, resultEquipment.Exp); + Assert.Equal( + (3_000_000 - expectedCost) * _currency, + nextState.GetBalance(_agentAddress, _currency) + ); + + var arenaSheet = _tableSheets.ArenaSheet; + var arenaData = arenaSheet.GetRoundByBlockIndex(1); + var feeStoreAddress = + Addresses.GetBlacksmithFeeAddress(arenaData.ChampionshipId, arenaData.Round); + Assert.Equal( + expectedCost * _currency, + nextState.GetBalance(feeStoreAddress, _currency) + ); + Assert.Equal(30, nextAvatarState.mailBox.Count); + + var stateDict = (Dictionary)nextState.GetState(slotAddress); + var slot = new CombinationSlotState(stateDict); + var slotResult = (ItemEnhancement12.ResultModel)slot.Result; + if (startLevel != expectedLevel) + { + var baseMinAtk = (decimal)preItemUsable.StatsMap.BaseATK; + var baseMaxAtk = (decimal)preItemUsable.StatsMap.BaseATK; + var extraMinAtk = (decimal)preItemUsable.StatsMap.AdditionalATK; + var extraMaxAtk = (decimal)preItemUsable.StatsMap.AdditionalATK; + + for (var i = startLevel + 1; i <= expectedLevel; i++) + { + var currentRow = _tableSheets.EnhancementCostSheetV3.OrderedList + .First(x => + x.Grade == 1 && x.ItemSubType == equipment.ItemSubType && x.Level == i); + + baseMinAtk *= currentRow.BaseStatGrowthMin.NormalizeFromTenThousandths() + 1; + baseMaxAtk *= currentRow.BaseStatGrowthMax.NormalizeFromTenThousandths() + 1; + extraMinAtk *= currentRow.ExtraStatGrowthMin.NormalizeFromTenThousandths() + 1; + extraMaxAtk *= currentRow.ExtraStatGrowthMax.NormalizeFromTenThousandths() + 1; + } + + Assert.InRange( + resultEquipment.StatsMap.ATK, + baseMinAtk + extraMinAtk, + baseMaxAtk + extraMaxAtk + 1 + ); + } + + Assert.Equal( + expectedBlockIndex + 1, // +1 for execution + resultEquipment.RequiredBlockIndex + ); + Assert.Equal(preItemUsable.ItemId, slotResult.preItemUsable.ItemId); + Assert.Equal(preItemUsable.ItemId, resultEquipment.ItemId); + } + } +} diff --git a/.Lib9c.Tests/Action/ItemEnhancementTest.cs b/.Lib9c.Tests/Action/ItemEnhancementTest.cs index 462d0a839f..e3a7e91e88 100644 --- a/.Lib9c.Tests/Action/ItemEnhancementTest.cs +++ b/.Lib9c.Tests/Action/ItemEnhancementTest.cs @@ -98,6 +98,11 @@ public ItemEnhancementTest() [InlineData(0, false, 1, 0, true, 3)] [InlineData(0, true, 1, 0, false, 3)] [InlineData(0, true, 1, 0, true, 3)] + // // Duplicated > from 0 to 0 + [InlineData(0, false, 0, 0, false, 3, true)] + [InlineData(0, false, 0, 0, true, 3, true)] + [InlineData(0, true, 0, 0, false, 3, true)] + [InlineData(0, true, 0, 0, true, 3, true)] // from 0 to N using multiple level 0 materials [InlineData(0, false, 2, 0, false, 7)] [InlineData(0, false, 4, 0, false, 31)] @@ -107,6 +112,15 @@ public ItemEnhancementTest() [InlineData(0, true, 4, 0, false, 31)] [InlineData(0, true, 2, 0, true, 7)] [InlineData(0, true, 4, 0, true, 31)] + // // Duplicated > from 0 to 0 + [InlineData(0, false, 0, 0, false, 7, true)] + [InlineData(0, false, 0, 0, false, 31, true)] + [InlineData(0, false, 0, 0, true, 7, true)] + [InlineData(0, false, 0, 0, true, 31, true)] + [InlineData(0, true, 0, 0, false, 7, true)] + [InlineData(0, true, 0, 0, false, 31, true)] + [InlineData(0, true, 0, 0, true, 7, true)] + [InlineData(0, true, 0, 0, true, 31, true)] // from K to K with material(s). Check requiredBlock == 0 [InlineData(10, false, 10, 0, false, 1)] [InlineData(10, false, 10, 0, true, 1)] @@ -126,6 +140,15 @@ public ItemEnhancementTest() [InlineData(5, true, 9, 7, false, 5)] [InlineData(5, true, 7, 4, true, 6)] [InlineData(5, true, 9, 7, true, 5)] + // // Duplicated: from K to K + [InlineData(5, true, 5, 4, true, 6, true)] + [InlineData(5, true, 7, 7, true, 5, true)] + [InlineData(5, true, 5, 4, false, 6, true)] + [InlineData(5, true, 7, 7, false, 5, true)] + [InlineData(5, false, 5, 4, true, 6, true)] + [InlineData(5, false, 7, 7, true, 5, true)] + [InlineData(5, false, 5, 4, false, 6, true)] + [InlineData(5, false, 7, 7, false, 5, true)] // from 20 to 21 (just to reach level 21 exp) [InlineData(20, false, 21, 20, false, 1)] [InlineData(20, false, 21, 20, true, 1)] @@ -145,14 +168,15 @@ public ItemEnhancementTest() [InlineData(21, true, 21, 21, false, 1)] [InlineData(21, true, 21, 1, true, 1)] [InlineData(21, true, 21, 21, true, 1)] - // Test: change of exp, change of level, required block, NCG price public void Execute( int startLevel, bool oldStart, int expectedLevel, int materialLevel, bool oldMaterial, - int materialCount) + int materialCount, + bool duplicated = false + ) { var row = _tableSheets.EquipmentItemSheet.Values.First(r => r.Id == 10110000); var equipment = (Equipment)ItemFactory.CreateItemUsable(row, default, 0, startLevel); @@ -188,9 +212,10 @@ public void Execute( var expectedExpIncrement = 0L; var materialIds = new List(); + var duplicatedGuid = Guid.NewGuid(); for (var i = 0; i < materialCount; i++) { - var materialId = Guid.NewGuid(); + var materialId = duplicated ? duplicatedGuid : Guid.NewGuid(); materialIds.Add(materialId); var material = (Equipment)ItemFactory.CreateItemUsable(row, materialId, 0, materialLevel); @@ -205,7 +230,11 @@ public void Execute( r.Level == material.level).Exp; } - expectedExpIncrement += material.Exp; + if (!(duplicated && i > 0)) + { + expectedExpIncrement += material.Exp; + } + if (oldMaterial) { material.Exp = 0L; @@ -263,7 +292,7 @@ public void Execute( ) .SetState(_avatarAddress, _avatarState.SerializeV2()); - var action = new ItemEnhancement() + var action = new ItemEnhancement { itemId = default, materialIds = materialIds, @@ -333,8 +362,8 @@ public void Execute( expectedBlockIndex + 1, // +1 for execution resultEquipment.RequiredBlockIndex ); - Assert.Equal(preItemUsable.TradableId, slotResult.preItemUsable.TradableId); - Assert.Equal(preItemUsable.TradableId, resultEquipment.TradableId); + Assert.Equal(preItemUsable.ItemId, slotResult.preItemUsable.ItemId); + Assert.Equal(preItemUsable.ItemId, resultEquipment.ItemId); } } } diff --git a/Lib9c/Action/AttachmentActionResult.cs b/Lib9c/Action/AttachmentActionResult.cs index 24927fc514..40e09375b1 100644 --- a/Lib9c/Action/AttachmentActionResult.cs +++ b/Lib9c/Action/AttachmentActionResult.cs @@ -22,7 +22,8 @@ private static readonly Dictionary new ItemEnhancement7.ResultModel(d), ["item_enhancement9.result"] = d => new ItemEnhancement9.ResultModel(d), ["item_enhancement11.result"] = d => new ItemEnhancement11.ResultModel(d), - ["item_enhancement12.result"] = d => new ItemEnhancement.ResultModel(d), + ["item_enhancement12.result"] = d => new ItemEnhancement12.ResultModel(d), + ["item_enhancement13.result"] = d => new ItemEnhancement.ResultModel(d), ["sellCancellation.result"] = d => new SellCancellation.Result(d), ["rapidCombination.result"] = d => new RapidCombination0.ResultModel(d), ["rapid_combination5.result"] = d => new RapidCombination5.ResultModel(d), diff --git a/Lib9c/Action/ItemEnhancement.cs b/Lib9c/Action/ItemEnhancement.cs index 9e45cdaa07..e57ebf1f28 100644 --- a/Lib9c/Action/ItemEnhancement.cs +++ b/Lib9c/Action/ItemEnhancement.cs @@ -27,7 +27,7 @@ namespace Nekoyume.Action /// Updated at https://github.com/planetarium/lib9c/pull/2068 /// [Serializable] - [ActionType("item_enhancement12")] + [ActionType("item_enhancement13")] public class ItemEnhancement : GameAction, IItemEnhancementV4 { public enum EnhancementResult @@ -53,7 +53,7 @@ public enum EnhancementResult [Serializable] public class ResultModel : AttachmentActionResult { - protected override string TypeId => "item_enhancement12.result"; + protected override string TypeId => "item_enhancement13.result"; public Guid id; public IEnumerable materialItemIdList; public BigInteger gold; @@ -253,14 +253,15 @@ public override IAccountStateDelta Execute(IActionContext context) } // Validate enhancement materials - if (!materialIds.Any() || materialIds.Count > MaterialCountLimit) + var uniqueMaterialIds = materialIds.Distinct().ToList(); + if (!uniqueMaterialIds.Any() || uniqueMaterialIds.Count > MaterialCountLimit) { throw new InvalidItemCountException(); } var materialEquipments = new List(); - foreach (var materialId in materialIds) + foreach (var materialId in uniqueMaterialIds) { if (!avatarState.inventory.TryGetNonFungibleItem(materialId, out ItemUsable materialItem)) @@ -377,7 +378,7 @@ public override IAccountStateDelta Execute(IActionContext context) enhancementEquipment.Update(requiredBlockIndex); // Remove materials - foreach (var materialId in materialIds) + foreach (var materialId in uniqueMaterialIds) { avatarState.inventory.RemoveNonFungibleItem(materialId); } @@ -391,7 +392,7 @@ public override IAccountStateDelta Execute(IActionContext context) { preItemUsable = preItemUsable, itemUsable = enhancementEquipment, - materialItemIdList = materialIds.ToArray(), + materialItemIdList = uniqueMaterialIds.ToArray(), actionPoint = requiredActionPoint, enhancementResult = EnhancementResult.Success, // Result is fixed to Success gold = requiredNcg, diff --git a/Lib9c/Action/ItemEnhancement12.cs b/Lib9c/Action/ItemEnhancement12.cs new file mode 100644 index 0000000000..0ca8b6c48a --- /dev/null +++ b/Lib9c/Action/ItemEnhancement12.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Numerics; +using Bencodex.Types; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Extensions; +using Nekoyume.Helper; +using Nekoyume.Model.Item; +using Nekoyume.Model.Mail; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using Nekoyume.TableData.Crystal; +using Serilog; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + /// + /// Updated at https://github.com/planetarium/lib9c/pull/2068 + /// + [Serializable] + [ActionObsolete(ActionObsoleteConfig.V200071ObsoleteIndex)] + [ActionType("item_enhancement12")] + public class ItemEnhancement12 : GameAction, IItemEnhancementV4 + { + public enum EnhancementResult + { + // Result is fixed to Success. + // GreatSuccess = 0, + Success = 1, + // Fail = 2, + } + + public const int MaterialCountLimit = 50; + + public Guid itemId; + public List materialIds; + public Address avatarAddress; + public int slotIndex; + + Guid IItemEnhancementV4.ItemId => itemId; + List IItemEnhancementV4.MaterialIds => materialIds; + Address IItemEnhancementV4.AvatarAddress => avatarAddress; + int IItemEnhancementV4.SlotIndex => slotIndex; + + [Serializable] + public class ResultModel : AttachmentActionResult + { + protected override string TypeId => "item_enhancement12.result"; + public Guid id; + public IEnumerable materialItemIdList; + public BigInteger gold; + public int actionPoint; + public EnhancementResult enhancementResult; + public ItemUsable preItemUsable; + public FungibleAssetValue CRYSTAL; + + public ResultModel() + { + } + + public ResultModel(Dictionary serialized) : base(serialized) + { + id = serialized["id"].ToGuid(); + materialItemIdList = + serialized["materialItemIdList"].ToList(StateExtensions.ToGuid); + gold = serialized["gold"].ToBigInteger(); + actionPoint = serialized["actionPoint"].ToInteger(); + enhancementResult = serialized["enhancementResult"].ToEnum(); + preItemUsable = serialized.ContainsKey("preItemUsable") + ? (ItemUsable)ItemFactory.Deserialize((Dictionary)serialized["preItemUsable"]) + : null; + CRYSTAL = serialized["c"].ToFungibleAssetValue(); + } + + public override IValue Serialize() => +#pragma warning disable LAA1002 + new Dictionary(new Dictionary + { + [(Text)"id"] = id.Serialize(), + [(Text)"materialItemIdList"] = materialItemIdList + .OrderBy(i => i) + .Select(g => g.Serialize()).Serialize(), + [(Text)"gold"] = gold.Serialize(), + [(Text)"actionPoint"] = actionPoint.Serialize(), + [(Text)"enhancementResult"] = enhancementResult.Serialize(), + [(Text)"preItemUsable"] = preItemUsable.Serialize(), + [(Text)"c"] = CRYSTAL.Serialize(), + }.Union((Dictionary)base.Serialize())); +#pragma warning restore LAA1002 + } + + protected override IImmutableDictionary PlainValueInternal + { + get + { + var dict = new Dictionary + { + ["itemId"] = itemId.Serialize(), + ["materialIds"] = new List( + materialIds.OrderBy(i => i).Select(i => i.Serialize()) + ), + ["avatarAddress"] = avatarAddress.Serialize(), + ["slotIndex"] = slotIndex.Serialize(), + }; + + return dict.ToImmutableDictionary(); + } + } + + protected override void LoadPlainValueInternal(IImmutableDictionary plainValue) + { + itemId = plainValue["itemId"].ToGuid(); + materialIds = plainValue["materialIds"].ToList(StateExtensions.ToGuid); + avatarAddress = plainValue["avatarAddress"].ToAddress(); + if (plainValue.TryGetValue((Text)"slotIndex", out var value)) + { + slotIndex = value.ToInteger(); + } + } + + public override IAccountStateDelta Execute(IActionContext context) + { + context.UseGas(1); + var ctx = context; + var states = ctx.PreviousState; + + if (ctx.Rehearsal) + { + return states; + } + + // Collect addresses + var slotAddress = avatarAddress.Derive( + string.Format( + CultureInfo.InvariantCulture, + CombinationSlotState.DeriveFormat, + slotIndex + ) + ); + var inventoryAddress = avatarAddress.Derive(LegacyInventoryKey); + var worldInformationAddress = avatarAddress.Derive(LegacyWorldInformationKey); + var questListAddress = avatarAddress.Derive(LegacyQuestListKey); + var addressesHex = GetSignerAndOtherAddressesHex(context, avatarAddress); + + var sw = new Stopwatch(); + sw.Start(); + var started = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex} ItemEnhancement exec started", addressesHex); + + // Validate avatar + if (!states.TryGetAgentAvatarStatesV2(ctx.Signer, avatarAddress, out var agentState, + out var avatarState, out var migrationRequired)) + { + throw new FailedLoadStateException( + $"{addressesHex} Aborted as the avatar state of the signer was failed to load." + ); + } + + // Validate AP + var requiredActionPoint = GetRequiredAp(); + if (avatarState.actionPoint < requiredActionPoint) + { + throw new NotEnoughActionPointException( + $"{addressesHex} Aborted due to insufficient action point: {avatarState.actionPoint} < {requiredActionPoint}" + ); + } + + // Validate target equipment item + if (!avatarState.inventory.TryGetNonFungibleItem(itemId, + out ItemUsable enhancementItem)) + { + throw new ItemDoesNotExistException( + $"{addressesHex} Aborted as the NonFungibleItem ({itemId}) was failed to load from avatar's inventory." + ); + } + + if (enhancementItem.RequiredBlockIndex > context.BlockIndex) + { + throw new RequiredBlockIndexException( + $"{addressesHex} Aborted as the equipment to enhance ({itemId}) is not available yet;" + + $" it will be available at the block #{enhancementItem.RequiredBlockIndex}." + ); + } + + if (!(enhancementItem is Equipment enhancementEquipment)) + { + throw new InvalidCastException( + $"{addressesHex} Aborted as the item is not a {nameof(Equipment)}, but {enhancementItem.GetType().Name}." + ); + } + + // Validate combination slot + var slotState = states.GetCombinationSlotState(avatarAddress, slotIndex); + if (slotState is null) + { + throw new FailedLoadStateException( + $"{addressesHex} Aborted as the slot state was failed to load. #{slotIndex}" + ); + } + + if (!slotState.Validate(avatarState, ctx.BlockIndex)) + { + throw new CombinationSlotUnlockException( + $"{addressesHex} Aborted as the slot state was failed to invalid. #{slotIndex}" + ); + } + + sw.Stop(); + Log.Verbose("{AddressesHex} ItemEnhancement Get Equipment: {Elapsed}", addressesHex, + sw.Elapsed); + + sw.Restart(); + + Dictionary sheets = states.GetSheets(sheetTypes: new[] + { + typeof(EquipmentItemSheet), + typeof(EnhancementCostSheetV3), + typeof(MaterialItemSheet), + typeof(CrystalEquipmentGrindingSheet), + typeof(CrystalMonsterCollectionMultiplierSheet), + typeof(StakeRegularRewardSheet) + }); + + // Validate from sheet + var enhancementCostSheet = sheets.GetSheet(); + EnhancementCostSheetV3.Row startCostRow; + if (enhancementEquipment.level == 0) + { + startCostRow = new EnhancementCostSheetV3.Row(); + } + else + { + if (!TryGetRow(enhancementEquipment, enhancementCostSheet, out startCostRow)) + { + throw new SheetRowNotFoundException(addressesHex, nameof(WorldSheet), + enhancementEquipment.level); + } + } + + var maxLevel = GetEquipmentMaxLevel(enhancementEquipment, enhancementCostSheet); + if (enhancementEquipment.level > maxLevel) + { + throw new EquipmentLevelExceededException( + $"{addressesHex} Aborted due to invalid equipment level: {enhancementEquipment.level} < {maxLevel}"); + } + + // Validate enhancement materials + if (!materialIds.Any() || materialIds.Count > MaterialCountLimit) + { + throw new InvalidItemCountException(); + } + + var materialEquipments = new List(); + + foreach (var materialId in materialIds) + { + if (!avatarState.inventory.TryGetNonFungibleItem(materialId, + out ItemUsable materialItem)) + { + throw new NotEnoughMaterialException( + $"{addressesHex} Aborted as the signer does not have a necessary material ({materialId})." + ); + } + + if (materialItem.RequiredBlockIndex > context.BlockIndex) + { + throw new RequiredBlockIndexException( + $"{addressesHex} Aborted as the material ({materialId}) is not available yet;" + + $" it will be available at the block #{materialItem.RequiredBlockIndex}." + ); + } + + if (!(materialItem is Equipment materialEquipment)) + { + throw new InvalidCastException( + $"{addressesHex} Aborted as the material item is not an {nameof(Equipment)}, but {materialItem.GetType().Name}." + ); + } + + if (enhancementEquipment.ItemId == materialId) + { + throw new InvalidMaterialException( + $"{addressesHex} Aborted as an equipment to enhance ({materialId}) was used as a material too." + ); + } + + if (materialEquipment.ItemSubType != enhancementEquipment.ItemSubType) + { + throw new InvalidMaterialException( + $"{addressesHex} Aborted as the material item is not a {enhancementEquipment.ItemSubType}," + + $" but {materialEquipment.ItemSubType}." + ); + } + + materialEquipments.Add(materialEquipment); + } + + sw.Stop(); + Log.Verbose("{AddressesHex} ItemEnhancement Get Material: {Elapsed}", + addressesHex, sw.Elapsed); + + sw.Restart(); + + // Do the action + var equipmentItemSheet = sheets.GetSheet(); + // Subtract required action point + avatarState.actionPoint -= requiredActionPoint; + + // Unequip items + enhancementEquipment.Unequip(); + foreach (var materialEquipment in materialEquipments) + { + materialEquipment.Unequip(); + } + + // clone enhancement item + var preItemUsable = new Equipment((Dictionary)enhancementEquipment.Serialize()); + + // Equipment level up & Update + enhancementEquipment.Exp = enhancementEquipment.GetRealExp(equipmentItemSheet, + enhancementCostSheet); + + enhancementEquipment.Exp += + materialEquipments.Aggregate(0L, + (total, m) => total + m.GetRealExp(equipmentItemSheet, enhancementCostSheet)); + var row = enhancementCostSheet + .OrderByDescending(r => r.Value.Exp) + .FirstOrDefault(row => + row.Value.ItemSubType == enhancementEquipment.ItemSubType && + row.Value.Grade == enhancementEquipment.Grade && + row.Value.Exp <= enhancementEquipment.Exp + ).Value; + if (!(row is null) && row.Level > enhancementEquipment.level) + { + enhancementEquipment.SetLevel(ctx.Random, row.Level, enhancementCostSheet); + } + + EnhancementCostSheetV3.Row targetCostRow; + if (enhancementEquipment.level == 0) + { + targetCostRow = new EnhancementCostSheetV3.Row(); + } + else + { + if (!TryGetRow(enhancementEquipment, enhancementCostSheet, out targetCostRow)) + { + throw new SheetRowNotFoundException(addressesHex, nameof(WorldSheet), + enhancementEquipment.level); + } + } + + // TransferAsset (NCG) + // Total cost = Total cost to reach target level - total cost to reach start level (already used) + var requiredNcg = targetCostRow.Cost - startCostRow.Cost; + if (requiredNcg > 0) + { + var arenaSheet = states.GetSheet(); + var arenaData = arenaSheet.GetRoundByBlockIndex(context.BlockIndex); + var feeStoreAddress = + Addresses.GetBlacksmithFeeAddress(arenaData.ChampionshipId, arenaData.Round); + states = states.TransferAsset(ctx, ctx.Signer, feeStoreAddress, + states.GetGoldCurrency() * requiredNcg); + } + + // Required block index = Total required block to reach target level - total required block to reach start level (already elapsed) + var requiredBlockIndex = + ctx.BlockIndex + + (targetCostRow.RequiredBlockIndex - startCostRow.RequiredBlockIndex); + enhancementEquipment.Update(requiredBlockIndex); + + // Remove materials + foreach (var materialId in materialIds) + { + avatarState.inventory.RemoveNonFungibleItem(materialId); + } + + sw.Stop(); + Log.Verbose("{AddressesHex} ItemEnhancement Upgrade Equipment: {Elapsed}", addressesHex, + sw.Elapsed); + + // Send scheduled mail + var result = new ResultModel + { + preItemUsable = preItemUsable, + itemUsable = enhancementEquipment, + materialItemIdList = materialIds.ToArray(), + actionPoint = requiredActionPoint, + enhancementResult = EnhancementResult.Success, // Result is fixed to Success + gold = requiredNcg, + CRYSTAL = 0 * CrystalCalculator.CRYSTAL, + }; + + var mail = new ItemEnhanceMail( + result, ctx.BlockIndex, ctx.Random.GenerateRandomGuid(), requiredBlockIndex + ); + result.id = mail.id; + avatarState.inventory.RemoveNonFungibleItem(enhancementEquipment); + avatarState.Update(mail); + avatarState.UpdateFromItemEnhancement(enhancementEquipment); + + // Update quest reward + var materialSheet = sheets.GetSheet(); + avatarState.UpdateQuestRewards(materialSheet); + + // Update slot state + slotState.Update(result, ctx.BlockIndex, requiredBlockIndex); + + // Set state + sw.Restart(); + states = states + .SetState(inventoryAddress, avatarState.inventory.Serialize()) + .SetState(worldInformationAddress, avatarState.worldInformation.Serialize()) + .SetState(questListAddress, avatarState.questList.Serialize()) + .SetState(avatarAddress, avatarState.SerializeV2()); + + sw.Stop(); + Log.Verbose("{AddressesHex} ItemEnhancement Set AvatarState: {Elapsed}", addressesHex, + sw.Elapsed); + var ended = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex} ItemEnhancement Total Executed Time: {Elapsed}", addressesHex, + ended - started); + return states.SetState(slotAddress, slotState.Serialize()); + } + + public static int GetRequiredBlockCount(Equipment preEquipment, Equipment targetEquipment, + EnhancementCostSheetV3 sheet) + { + return sheet.OrderedList + .Where(e => + e.ItemSubType == targetEquipment.ItemSubType && + e.Grade == targetEquipment.Grade && + e.Level > preEquipment.level && + e.Level <= targetEquipment.level) + .Aggregate(0, (blocks, row) => blocks + row.RequiredBlockIndex); + } + + public static bool TryGetRow(Equipment equipment, EnhancementCostSheetV3 sheet, + out EnhancementCostSheetV3.Row row) + { + row = sheet.OrderedList.FirstOrDefault(x => + x.Grade == equipment.Grade && + x.Level == equipment.level && + x.ItemSubType == equipment.ItemSubType + ); + return row != null; + } + + public static int GetEquipmentMaxLevel(Equipment equipment, EnhancementCostSheetV3 sheet) + { + return sheet.OrderedList.Where(x => x.Grade == equipment.Grade).Max(x => x.Level); + } + + public static int GetRequiredAp() + { + return GameConfig.EnhanceEquipmentCostAP; + } + } +} diff --git a/Lib9c/ActionObsoleteConfig.cs b/Lib9c/ActionObsoleteConfig.cs index c53eb88fa7..0a00c6c991 100644 --- a/Lib9c/ActionObsoleteConfig.cs +++ b/Lib9c/ActionObsoleteConfig.cs @@ -78,6 +78,8 @@ public static class ActionObsoleteConfig public const long V200070ObsoleteIndex = 7_716_400L; + public const long V200071ObsoleteIndex = 7_718_878L; + // While v200020, the action obsolete wasn't work well. // So other previous `V*ObsoletedIndex`s lost its meaning and // this block index will replace them.