diff --git a/.Lib9c.Tests/Action/ClaimPatrolRewardTest.cs b/.Lib9c.Tests/Action/ClaimPatrolRewardTest.cs new file mode 100644 index 0000000000..17920e4d96 --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimPatrolRewardTest.cs @@ -0,0 +1,111 @@ +namespace Lib9c.Tests.Action +{ + using System.Linq; + using Lib9c.Tests.Util; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Mocks; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model.Mail; + using Nekoyume.Model.State; + using Nekoyume.Module; + using Serilog; + using Xunit; + using Xunit.Abstractions; + + public class ClaimPatrolRewardTest + { + private readonly IWorld _initialState; + private readonly TableSheets _tableSheets; + + public ClaimPatrolRewardTest(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + _initialState = new World(MockUtil.MockModernWorldState); + + var sheets = TableSheetsImporter.ImportSheets(); + foreach (var (key, value) in sheets) + { + _initialState = _initialState + .SetLegacyState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + _tableSheets = new TableSheets(sheets); + } + + [Fact] + public void Execute() + { + var privateKey = new PrivateKey(); + var agentAddress = privateKey.Address; + var row = _tableSheets.PatrolRewardSheet.Values.First(); + var (state, avatar, _) = InitializeUtil.AddAvatar(_initialState, _tableSheets.GetAvatarSheets(), agentAddress); + var avatarAddress = avatar.address; + var action = new ClaimPatrolReward(avatar.address); + + var nextState = action.Execute(new ActionContext + { + Signer = agentAddress, + BlockIndex = 1L, + PreviousState = state, + RandomSeed = 0, + }); + + var avatarState = nextState.GetAvatarState(avatarAddress); + var itemSheet = _tableSheets.ItemSheet; + var mail = Assert.IsType(avatarState.mailBox.Single()); + + foreach (var reward in row.Rewards) + { + var ticker = reward.Ticker; + if (string.IsNullOrEmpty(ticker)) + { + var itemId = reward.ItemId; + var rowId = itemSheet[itemId].Id; + Assert.True(avatarState.inventory.HasItem(rowId, reward.Count)); + var item = mail.Items.First(i => i.id == itemId); + Assert.Equal(item.count, reward.Count); + } + else + { + var currency = Currencies.GetMinterlessCurrency(ticker); + var recipient = Currencies.PickAddress(currency, agentAddress, avatarAddress); + var fav = nextState.GetBalance(recipient, currency); + Assert.Equal(currency * reward.Count, fav); + Assert.Contains(fav, mail.FungibleAssetValues); + } + } + + Assert.Equal(1L, nextState.GetPatrolRewardClaimedBlockIndex(avatarAddress)); + Assert.True(row.Interval > 1L); + + // Throw RequiredBlockIndex by reward interval + Assert.Throws(() => action.Execute(new ActionContext + { + Signer = agentAddress, + BlockIndex = 2L, + PreviousState = nextState, + RandomSeed = 0, + })); + } + + [Fact] + public void Execute_Throw_InvalidAddressException() + { + var signer = new PrivateKey().Address; + var action = new ClaimPatrolReward(signer); + + Assert.Throws(() => action.Execute(new ActionContext + { + Signer = signer, + BlockIndex = 0, + PreviousState = _initialState, + })); + } + } +} diff --git a/.Lib9c.Tests/TableData/Event/PatrolRewardSheetTest.cs b/.Lib9c.Tests/TableData/Event/PatrolRewardSheetTest.cs new file mode 100644 index 0000000000..1d945999c9 --- /dev/null +++ b/.Lib9c.Tests/TableData/Event/PatrolRewardSheetTest.cs @@ -0,0 +1,88 @@ +namespace Lib9c.Tests.TableData.Event +{ + using System.Linq; + using Nekoyume.TableData.Event; + using Xunit; + + public class PatrolRewardSheetTest + { + private readonly PatrolRewardSheet _sheet = new (); + + public PatrolRewardSheetTest() + { + const string csv = + "id,start,end,interval,min_level,max_level,reward_count,reward_item_id,reward_ticker,reward_count,reward_item_id,reward_ticker,reward_count,reward_item_id,reward_ticker,reward_count,reward_item_id,reward_ticker\n1,0,100,8400,1,200,1,500000,,1,600201,\n2,100,200,8400,201,350,1,500000,,2,600201,,100000,,CRYSTAL\n3,200,300,8400,351,,2,500000,,3,600201,,200000,,CRYSTAL,1,600202,\n"; + _sheet.Set(csv); + } + + [Fact] + public void Set() + { + var row = _sheet[1]; + Assert.Equal(0L, row.StartedBlockIndex); + Assert.Equal(100L, row.EndedBlockIndex); + Assert.Equal(8400L, row.Interval); + Assert.Equal(1, row.MinimumLevel); + Assert.Equal(200, row.MaxLevel); + var apReward = row.Rewards.First(); + Assert.Equal(1, apReward.Count); + Assert.Equal(500000, apReward.ItemId); + Assert.True(string.IsNullOrEmpty(apReward.Ticker)); + var gdReward = row.Rewards.Last(); + Assert.Equal(1, gdReward.Count); + Assert.Equal(600201, gdReward.ItemId); + Assert.True(string.IsNullOrEmpty(gdReward.Ticker)); + + row = _sheet[2]; + Assert.Equal(100L, row.StartedBlockIndex); + Assert.Equal(200L, row.EndedBlockIndex); + Assert.Equal(8400L, row.Interval); + Assert.Equal(201, row.MinimumLevel); + Assert.Equal(350, row.MaxLevel); + apReward = row.Rewards.First(); + Assert.Equal(1, apReward.Count); + Assert.Equal(500000, apReward.ItemId); + Assert.True(string.IsNullOrEmpty(apReward.Ticker)); + gdReward = row.Rewards[1]; + Assert.Equal(2, gdReward.Count); + Assert.Equal(600201, gdReward.ItemId); + Assert.True(string.IsNullOrEmpty(gdReward.Ticker)); + var crystalReward = row.Rewards.Last(); + Assert.Equal(100000, crystalReward.Count); + Assert.Equal(0, crystalReward.ItemId); + Assert.Equal("CRYSTAL", crystalReward.Ticker); + + row = _sheet[3]; + Assert.Equal(200L, row.StartedBlockIndex); + Assert.Equal(300L, row.EndedBlockIndex); + Assert.Equal(8400L, row.Interval); + Assert.Equal(351, row.MinimumLevel); + Assert.Null(row.MaxLevel); + apReward = row.Rewards.First(); + Assert.Equal(2, apReward.Count); + Assert.Equal(500000, apReward.ItemId); + Assert.True(string.IsNullOrEmpty(apReward.Ticker)); + gdReward = row.Rewards[1]; + Assert.Equal(3, gdReward.Count); + Assert.Equal(600201, gdReward.ItemId); + Assert.True(string.IsNullOrEmpty(gdReward.Ticker)); + crystalReward = row.Rewards[2]; + Assert.Equal(200000, crystalReward.Count); + Assert.Equal(0, crystalReward.ItemId); + Assert.Equal("CRYSTAL", crystalReward.Ticker); + var rdReward = row.Rewards.Last(); + Assert.Equal(1, rdReward.Count); + Assert.Equal(600202, rdReward.ItemId); + Assert.True(string.IsNullOrEmpty(rdReward.Ticker)); + } + + [Theory] + [InlineData(2, 1, 0)] + [InlineData(350, 2, 150)] + [InlineData(500, 3, 300)] + public void FindByLevel(int level, int id, long blockIndex) + { + Assert.Equal(id, _sheet.FindByLevel(level, blockIndex).Id); + } + } +} diff --git a/.Lib9c.Tests/TableSheets.cs b/.Lib9c.Tests/TableSheets.cs index 92ebfd45ff..961e30b0da 100644 --- a/.Lib9c.Tests/TableSheets.cs +++ b/.Lib9c.Tests/TableSheets.cs @@ -285,6 +285,8 @@ public TableSheets(Dictionary sheets, bool ignoreFailedGetProper public SynthesizeWeightSheet SynthesizeWeightSheet { get; private set; } + public PatrolRewardSheet PatrolRewardSheet { get; private set; } + public void ItemSheetInitialize() { ItemSheet ??= new ItemSheet(); diff --git a/Lib9c/Action/ClaimGifts.cs b/Lib9c/Action/ClaimGifts.cs index c8b73a866b..656eff8984 100644 --- a/Lib9c/Action/ClaimGifts.cs +++ b/Lib9c/Action/ClaimGifts.cs @@ -6,6 +6,7 @@ using Libplanet.Crypto; using Nekoyume.Exceptions; using Nekoyume.Extensions; +using Nekoyume.Helper; using Nekoyume.Model.Item; using Nekoyume.Model.State; using Nekoyume.Module; @@ -123,21 +124,7 @@ public override IWorld Execute(IActionContext context) foreach (var (itemId, quantity, tradable) in giftRow.Items) { var itemRow = itemSheet[itemId]; - if (itemRow is MaterialItemSheet.Row materialRow) - { - var item = tradable - ? ItemFactory.CreateTradableMaterial(materialRow) - : ItemFactory.CreateMaterial(materialRow); - inventory.AddItem(item, quantity); - } - else - { - foreach (var _ in Enumerable.Range(0, quantity)) - { - var item = ItemFactory.CreateItem(itemRow, random); - inventory.AddItem(item); - } - } + inventory.MintItem(itemRow, quantity, tradable, random); } claimedGiftIds.Add(giftRow.Id); diff --git a/Lib9c/Action/ClaimItems.cs b/Lib9c/Action/ClaimItems.cs index 990e2b2e90..11d28be1af 100644 --- a/Lib9c/Action/ClaimItems.cs +++ b/Lib9c/Action/ClaimItems.cs @@ -9,6 +9,7 @@ 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; @@ -134,22 +135,7 @@ public override IWorld Execute(IActionContext context) // it's only right that this is fixed in Inventory. var itemRow = itemSheet[itemId]; var itemCount = (int)fungibleAssetValue.RawValue; - if (itemRow is MaterialItemSheet.Row materialRow) - { - var item = tradable - ? ItemFactory.CreateTradableMaterial(materialRow) - : ItemFactory.CreateMaterial(materialRow); - avatarState.inventory.AddItem(item, itemCount); - } - else - { - foreach (var _ in Enumerable.Range(0, itemCount)) - { - var item = ItemFactory.CreateItem(itemRow, random); - avatarState.inventory.AddItem(item); - } - } - + avatarState.inventory.MintItem(itemRow, itemCount, tradable, random); items.Add((itemRow.Id, itemCount)); } } diff --git a/Lib9c/Action/ClaimPatrolReward.cs b/Lib9c/Action/ClaimPatrolReward.cs new file mode 100644 index 0000000000..49e1f343d6 --- /dev/null +++ b/Lib9c/Action/ClaimPatrolReward.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using Bencodex.Types; +using Lib9c; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Extensions; +using Nekoyume.Helper; +using Nekoyume.Model.Mail; +using Nekoyume.Model.State; +using Nekoyume.Module; +using Nekoyume.TableData.Event; + +namespace Nekoyume.Action +{ + /// + /// Claim patrol reward + /// + [Serializable] + [ActionType(TypeIdentifier)] + public class ClaimPatrolReward : ActionBase + { + public const string TypeIdentifier = "claim_patrol_reward"; + + /// + /// The address of the avatar to receive the patrol reward. + /// + public Address AvatarAddress; + + public ClaimPatrolReward() + { + } + + public ClaimPatrolReward(Address avatarAddress) + { + AvatarAddress = avatarAddress; + } + public override IWorld Execute(IActionContext context) + { + GasTracer.UseGas(1); + var signer = context.Signer; + var states = context.PreviousState; + + // Validate that the avatar address belongs to the signer. + // This ensures that only the owner of the avatar can claim the patrol reward for it. + if (!Addresses.CheckAvatarAddrIsContainedInAgent(signer, AvatarAddress)) + { + throw new InvalidAddressException(); + } + + // avatar + var avatarState = states.GetAvatarState(AvatarAddress, true, false, false); + var avatarLevel = avatarState.level; + var inventory = avatarState.inventory; + + // sheets + var sheets = states.GetSheets(containItemSheet: true, sheetTypes: new[] + { + typeof(PatrolRewardSheet) + }); + var patrolRewardSheet = sheets.GetSheet(); + var itemSheet = sheets.GetItemSheet(); + + // validate + states.TryGetPatrolRewardClaimedBlockIndex(AvatarAddress, out var claimedBlockIndex); + var row = patrolRewardSheet.FindByLevel(avatarLevel, context.BlockIndex); + + // Ensure rewards cannot be claimed too frequently. + // If the last claimed block index is set and the current block index is less than the allowed interval, throw an exception. + if (claimedBlockIndex > 0L && claimedBlockIndex + row.Interval > context.BlockIndex) + { + throw new RequiredBlockIndexException(); + } + + // mint rewards + var random = context.GetRandom(); + var fav = new List(); + var items = new List<(int id, int count)>(); + foreach (var reward in row.Rewards) + { + var ticker = reward.Ticker; + if (string.IsNullOrEmpty(ticker)) + { + var itemRow = itemSheet[reward.ItemId]; + inventory.MintItem(itemRow, reward.Count, false, random); + items.Add(new (reward.ItemId, reward.Count)); + } + else + { + var currency = Currencies.GetMinterlessCurrency(ticker); + var recipient = Currencies.PickAddress(currency, signer, AvatarAddress); + var asset = currency * reward.Count; + states = states.MintAsset(context, recipient, asset); + fav.Add(asset); + } + } + + var mailBox = avatarState.mailBox; + var mail = new PatrolRewardMail(context.BlockIndex, random.GenerateRandomGuid(), context.BlockIndex, fav, items); + mailBox.Add(mail); + mailBox.CleanUp(); + avatarState.mailBox = mailBox; + + // set states + return states + .SetAvatarState(AvatarAddress, avatarState, setAvatar: true, setInventory: true, setWorldInformation: false, setQuestList: false) + .SetPatrolRewardClaimedBlockIndex(AvatarAddress, context.BlockIndex); + } + + public override IValue PlainValue => Dictionary.Empty + .Add("type_id", TypeIdentifier) + .Add("values", AvatarAddress.Serialize()); + public override void LoadPlainValue(IValue plainValue) + { + AvatarAddress = ((Dictionary)plainValue)["values"].ToAddress(); + } + } +} diff --git a/Lib9c/Addresses.cs b/Lib9c/Addresses.cs index 5a0ac42632..f0ee7666a5 100644 --- a/Lib9c/Addresses.cs +++ b/Lib9c/Addresses.cs @@ -54,6 +54,7 @@ public static class Addresses public static readonly Address CombinationSlot = new("0000000000000000000000000000000000000024"); public static readonly Address ClaimedGiftIds = new("0000000000000000000000000000000000000025"); + public static readonly Address PatrolReward = new("0000000000000000000000000000000000000026"); // Adventure Boss public static readonly Address AdventureBoss = new("0000000000000000000000000000000000000100"); @@ -106,7 +107,7 @@ public static Address GetGuildBanAccountAddress(Address guildAddress) => /// /// An address of an account having . - /// + /// public static readonly Address GuildDelegateeMetadata = new Address("0000000000000000000000000000000000000210"); diff --git a/Lib9c/Helper/InventoryExtensions.cs b/Lib9c/Helper/InventoryExtensions.cs index e45b1e8e04..2022ad81b4 100644 --- a/Lib9c/Helper/InventoryExtensions.cs +++ b/Lib9c/Helper/InventoryExtensions.cs @@ -1,4 +1,5 @@ using System.Linq; +using Libplanet.Action; using Libplanet.Action.State; using Nekoyume.Action; using Nekoyume.Model.Item; @@ -61,5 +62,33 @@ public static long UseActionPoint( originalAp -= requiredAp; return originalAp; } + + /// + /// Create and Add item. + /// + /// > + /// + /// create item count + /// item is tradable + /// > + public static void MintItem(this Inventory inventory, ItemSheet.Row itemRow, int quantity, + bool tradable, IRandom random) + { + if (itemRow is MaterialItemSheet.Row materialRow) + { + var item = tradable + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateMaterial(materialRow); + inventory.AddItem(item, quantity); + } + else + { + foreach (var _ in Enumerable.Range(0, quantity)) + { + var item = ItemFactory.CreateItem(itemRow, random); + inventory.AddItem(item); + } + } + } } } diff --git a/Lib9c/Model/Mail/IMail.cs b/Lib9c/Model/Mail/IMail.cs index 577eeec637..476471d15a 100644 --- a/Lib9c/Model/Mail/IMail.cs +++ b/Lib9c/Model/Mail/IMail.cs @@ -22,5 +22,6 @@ public interface IMail void Read(ClaimItemsMail claimItemsMail); void Read(AdventureBossRaffleWinnerMail adventureBossRaffleWinnerMail); void Read(CustomCraftMail customCraftMail); + void Read(PatrolRewardMail patrolRewardMail); } } diff --git a/Lib9c/Model/Mail/Mail.cs b/Lib9c/Model/Mail/Mail.cs index 8b7042bce6..a6fe1c47e8 100644 --- a/Lib9c/Model/Mail/Mail.cs +++ b/Lib9c/Model/Mail/Mail.cs @@ -45,6 +45,7 @@ public abstract class Mail : IState [nameof(ClaimItemsMail)] = d => new ClaimItemsMail(d), [nameof(AdventureBossRaffleWinnerMail)] = d => new AdventureBossRaffleWinnerMail(d), [nameof(CustomCraftMail)] = d => new CustomCraftMail(d), + [nameof(PatrolRewardMail)] = d => new PatrolRewardMail(d), }; public Guid id; diff --git a/Lib9c/Model/Mail/PatrolRewardMail.cs b/Lib9c/Model/Mail/PatrolRewardMail.cs new file mode 100644 index 0000000000..8b3a000b83 --- /dev/null +++ b/Lib9c/Model/Mail/PatrolRewardMail.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Bencodex.Types; +using Libplanet.Types.Assets; +using Nekoyume.Model.State; + +namespace Nekoyume.Model.Mail +{ + /// + /// Represents a patrol reward mail that contains rewards such as fungible assets and items. + /// + public class PatrolRewardMail : Mail + { + /// + /// The type of mail. Defaults to Auction. + /// + public override MailType MailType => MailType.Auction; + + /// + /// A list of fungible asset values included in the reward. + /// + public List FungibleAssetValues = new (); + + /// + /// A list of item rewards where each entry contains the item ID and count. + /// + public List<(int id, int count)> Items = new (); + + /// + /// Constructor for creating a PatrolRewardMail with specified parameters. + /// + /// The block index at which the mail is created. + /// The unique identifier for the mail. + /// The block index required to claim the mail. + /// The list of fungible asset rewards. + /// The list of item rewards. + public PatrolRewardMail(long blockIndex, Guid id, long requiredBlockIndex, + List fungibleAssetValues, List<(int id, int count)> items) + : base(blockIndex, id, requiredBlockIndex) + { + FungibleAssetValues = fungibleAssetValues; + Items = items; + } + + /// + /// Constructor for deserializing a PatrolRewardMail from a Bencodex dictionary. + /// + /// The serialized dictionary representation of the mail. + public PatrolRewardMail(Dictionary serialized) : base(serialized) + { + if (serialized.ContainsKey("f")) + { + FungibleAssetValues = serialized["f"].ToList(StateExtensions.ToFungibleAssetValue); + } + + if (serialized.ContainsKey("i")) + { + Items = serialized["i"].ToList<(int, int)>(v => + { + var list = (List) v; + return ((Integer)list[0], (Integer)list[1]); + }); + } + } + + /// + /// Reads the mail contents into the specified mail object. + /// + /// The mail object to populate with this mail's data. + public override void Read(IMail mail) + { + mail.Read(this); + } + + /// + /// The type identifier for the PatrolRewardMail class. + /// + protected override string TypeId => nameof(PatrolRewardMail); + + /// + /// Serializes the PatrolRewardMail into a Bencodex-compatible format. + /// + /// A serialized representation of the mail. + public override IValue Serialize() + { + var dict = (Dictionary)base.Serialize(); + if (FungibleAssetValues.Any()) + { + dict = dict.SetItem("f", new List(FungibleAssetValues.Select(f => f.Serialize()))); + } + + if (Items.Any()) + { + dict = dict.SetItem("i", + new List(Items.Select(tuple => List.Empty.Add(tuple.id).Add(tuple.count)))); + } + + return dict; + } + } +} diff --git a/Lib9c/Module/PatrolRewardModule.cs b/Lib9c/Module/PatrolRewardModule.cs new file mode 100644 index 0000000000..aec42cd9fc --- /dev/null +++ b/Lib9c/Module/PatrolRewardModule.cs @@ -0,0 +1,67 @@ +using Bencodex.Types; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Nekoyume.Action; + +namespace Nekoyume.Module +{ + /// + /// Provides utility methods for handling patrol reward states. + /// + public static class PatrolRewardModule + { + /// + /// Retrieves the block index at which the patrol reward was last claimed for a given avatar. + /// + /// The current world state. + /// The address of the avatar. + /// The block index when the patrol reward was last claimed. + /// Thrown if the state could not be loaded. + public static long GetPatrolRewardClaimedBlockIndex(this IWorldState worldState, Address avatarAddress) + { + var value = worldState.GetAccountState(Addresses.PatrolReward).GetState(avatarAddress); + if (value is Integer integer) + { + return integer; + } + + throw new FailedLoadStateException("Failed to load the patrol reward claimed block index state."); + } + + /// + /// Tries to retrieve the block index at which the patrol reward was last claimed for a given avatar. + /// + /// The current world state. + /// The address of the avatar. + /// Outputs the block index when the patrol reward was last claimed. + /// true if the block index was successfully retrieved; otherwise, false. + public static bool TryGetPatrolRewardClaimedBlockIndex(this IWorldState worldState, Address avatarAddress, out long blockIndex) + { + blockIndex = 0L; + try + { + var temp = GetPatrolRewardClaimedBlockIndex(worldState, avatarAddress); + blockIndex = temp; + return true; + } + catch (FailedLoadStateException) + { + return false; + } + } + + /// + /// Sets the block index at which the patrol reward was last claimed for a given avatar. + /// + /// The current world instance. + /// The address of the avatar. + /// The block index to set. + /// The updated world instance. + public static IWorld SetPatrolRewardClaimedBlockIndex(this IWorld world, Address avatarAddress, long blockIndex) + { + var account = world.GetAccount(Addresses.PatrolReward); + account = account.SetState(avatarAddress, (Integer)blockIndex); + return world.SetAccount(Addresses.PatrolReward, account); + } + } +} diff --git a/Lib9c/TableCSV/PatrolRewardSheet.csv b/Lib9c/TableCSV/PatrolRewardSheet.csv new file mode 100644 index 0000000000..301587e3a0 --- /dev/null +++ b/Lib9c/TableCSV/PatrolRewardSheet.csv @@ -0,0 +1,4 @@ +id,start,end,interval,min_level,max_level,reward1_count,reward1_item_id,reward1_ticker,reward2_count,reward2_item_id,reward2_ticker,reward3_count,reward3_item_id,reward3_ticker,reward4_count,reward4_item_id,reward4_ticker +1,0,100,8400,1,200,1,500000,,1,600201, +2,100,200,8400,201,350,1,500000,,2,600201,,100000,,CRYSTAL +3,200,300,8400,351,,2,500000,,3,600201,,200000,,CRYSTAL,1,600202, diff --git a/Lib9c/TableData/Event/PatrolRewardSheet.cs b/Lib9c/TableData/Event/PatrolRewardSheet.cs new file mode 100644 index 0000000000..92944fa81c --- /dev/null +++ b/Lib9c/TableData/Event/PatrolRewardSheet.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using static Nekoyume.TableData.TableExtensions; + +namespace Nekoyume.TableData.Event +{ + /// + /// Represents the PatrolRewardSheet, which defines patrol reward policies and rules. + /// + [Serializable] + public class PatrolRewardSheet : Sheet + { + /// + /// Represents a reward model consisting of item counts, IDs, and tickers. + /// + [Serializable] + public class RewardModel + { + public int Count; + public int ItemId; + public string Ticker; + + public RewardModel(int count, int itemId, string ticker) + { + Count = count; + ItemId = itemId; + Ticker = ticker; + } + } + + /// + /// Represents a row in the PatrolRewardSheet, defining patrol reward policies for specific levels and intervals. + /// + [Serializable] + public class Row : SheetRow + { + public const int MaxRewardCount = 100; + public override int Key => Id; + public int Id { get; set; } + public long StartedBlockIndex { get; set; } + public long EndedBlockIndex { get; set; } + public long Interval { get; set; } + public int MinimumLevel { get; set; } + public int? MaxLevel { get; set; } + public List Rewards { get; set; } = new (); + + /// + /// Sets the row data using a list of string fields. + /// + /// The fields to parse into the row. + public override void Set(IReadOnlyList fields) + { + Id = ParseInt(fields[0]); + StartedBlockIndex = ParseLong(fields[1]); + EndedBlockIndex = ParseLong(fields[2]); + Interval = ParseLong(fields[3]); + MinimumLevel = ParseInt(fields[4]); + if (string.IsNullOrEmpty(fields[5])) + { + MaxLevel = null; + } + else + { + MaxLevel = ParseInt(fields[5]); + } + + for (var i = 0; i < MaxRewardCount; i++) + { + var idx = 6 + i * 3; + if (fields.Count >= idx + 3 && + TryParseInt(fields[idx], out _)) + { + TryParseInt(fields[idx + 1], out var itemId); + Rewards.Add(new (ParseInt(fields[idx]), itemId, fields[idx + 2])); + } + else + { + break; + } + } + } + } + + /// + /// Finds the reward policy row based on avatar level and block index. + /// + /// The avatar's level. + /// The current block index. + /// The corresponding reward policy row. + /// Thrown if no activated policy matches the criteria. + public Row FindByLevel(int level, long blockIndex) + { + var orderedRows = Values + .Where(r => r.StartedBlockIndex <= blockIndex && blockIndex <= r.EndedBlockIndex) + .OrderByDescending(i => i.MinimumLevel).ToList(); + + if (!orderedRows.Any()) + { + throw new InvalidOperationException("can't find activated policy"); + } + + foreach (var row in orderedRows) + { + if (row.MinimumLevel <= level) + { + return row; + } + } + + return orderedRows.Last(); + } + + /// + /// Initializes a new instance of the class. + /// + public PatrolRewardSheet() : base(nameof(PatrolRewardSheet)) + { + } + } +}