From 84f97d661f439030327df322adce8ddbaf6e51d9 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Mon, 31 Aug 2026 19:49:26 +1200 Subject: [PATCH 1/2] feat: add cellon jewelry options (#815) Cellons add a permanent stat option to jewelry instead of raising an upgrade level. UpgradePacketType.CellonItem was already defined and the packet already carried the cellon slot, but no operation claimed the kind so up_gr with type 3 did nothing. The cellon carries its tier in Item.EffectValue; the jewel caps how many options it holds (Item.MaxCellon) and how strong they may be (Item.MaxCellonLvl). Success odds fall as the jewel fills, and the cellon is consumed either way. A jewel already holding every option its tier offers fails outright rather than reporting a success that adds nothing. Rolled options are persisted through IDao. They are not yet applied to a character's stats: EquipmentStats.Hp/Mp exist but nothing consumes them, so that wiring stays a follow-up. Adds a protected Roll helper on UpgradeOperation for operations that need more than the skeleton's single success check. Co-Authored-By: Claude Opus 5 (1M context) --- .../UpgradeService/CellonOperation.cs | 175 +++++++++++++ .../UpgradeService/UpgradeOperation.cs | 5 + .../UpgradeService/CellonOperationTests.cs | 239 ++++++++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 src/NosCore.GameObject/Services/UpgradeService/CellonOperation.cs create mode 100644 test/NosCore.GameObject.Tests/Services/UpgradeService/CellonOperationTests.cs diff --git a/src/NosCore.GameObject/Services/UpgradeService/CellonOperation.cs b/src/NosCore.GameObject/Services/UpgradeService/CellonOperation.cs new file mode 100644 index 000000000..3d2b37bc1 --- /dev/null +++ b/src/NosCore.GameObject/Services/UpgradeService/CellonOperation.cs @@ -0,0 +1,175 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JetBrains.Annotations; +using NosCore.Core.I18N; +using NosCore.Dao.Interfaces; +using NosCore.Data.Dto; +using NosCore.Data.Enumerations; +using NosCore.Data.Enumerations.Items; +using NosCore.GameObject.Ecs.Extensions; +using NosCore.GameObject.Networking.ClientSession; +using NosCore.GameObject.Services.InventoryService; +using NosCore.GameObject.Services.ItemGenerationService.Item; +using NosCore.Packets.ClientPackets.Player; +using NosCore.Packets.Enumerations; +using NosCore.Packets.Interfaces; + +namespace NosCore.GameObject.Services.UpgradeService; + +// Cellons add a permanent stat option to a piece of jewelry instead of raising its upgrade +// level. The cellon carries the option tier in Item.EffectValue, while the jewel caps both how +// many options it can hold (Item.MaxCellon) and how strong they may be (Item.MaxCellonLvl). +// +// The cellon is consumed on every attempt. Success odds fall as the jewel fills up, and a jewel +// that already carries every option its tier offers can no longer gain one. +[UsedImplicitly] +public sealed class CellonOperation( + IRandomNumberSource random, + IGameLanguageLocalizer localizer, + IDao equipmentOptionDao) + : UpgradeOperation(random, localizer) +{ + private static readonly long[] GoldCostByCellonLevel = + { 0, 700, 1400, 3000, 5000, 10000, 20000, 32000, 58000, 95000, 134900 }; + + private static readonly double[] SuccessRateByOptionCount = + { 0.85, 0.75, 0.65, 0.50, 0.40, 0.30 }; + + private static readonly CellonOption[][] OptionsByCellonLevel = + { + Array.Empty(), + new CellonOption[] { new(CellonType.Hp, 30, 100), new(CellonType.Mp, 50, 120), new(CellonType.HpRecovery, 5, 10), new(CellonType.MpRecovery, 8, 15) }, + new CellonOption[] { new(CellonType.Hp, 120, 200), new(CellonType.Mp, 150, 250), new(CellonType.HpRecovery, 14, 20), new(CellonType.MpRecovery, 16, 25) }, + new CellonOption[] { new(CellonType.Hp, 220, 330), new(CellonType.Mp, 280, 330), new(CellonType.HpRecovery, 22, 28), new(CellonType.MpRecovery, 28, 35) }, + new CellonOption[] { new(CellonType.Hp, 330, 400), new(CellonType.Mp, 350, 420), new(CellonType.HpRecovery, 30, 38), new(CellonType.MpRecovery, 38, 45) }, + new CellonOption[] { new(CellonType.Hp, 430, 550), new(CellonType.Mp, 450, 550), new(CellonType.HpRecovery, 40, 50), new(CellonType.MpRecovery, 50, 60) }, + new CellonOption[] { new(CellonType.Hp, 600, 750), new(CellonType.Mp, 600, 750), new(CellonType.HpRecovery, 55, 70), new(CellonType.MpRecovery, 65, 80), new(CellonType.MpConsumption, 1, 7), new(CellonType.CriticalDamageDecrease, 1, 7) }, + new CellonOption[] { new(CellonType.Hp, 800, 1000), new(CellonType.Mp, 800, 1000), new(CellonType.HpRecovery, 75, 90), new(CellonType.MpRecovery, 75, 90), new(CellonType.MpConsumption, 8, 12), new(CellonType.CriticalDamageDecrease, 11, 20) }, + new CellonOption[] { new(CellonType.Hp, 1000, 1300), new(CellonType.Mp, 1000, 1300), new(CellonType.HpRecovery, 100, 120), new(CellonType.MpRecovery, 100, 120), new(CellonType.MpConsumption, 13, 17), new(CellonType.CriticalDamageDecrease, 21, 35) }, + new CellonOption[] { new(CellonType.Hp, 1100, 1500), new(CellonType.Mp, 1100, 1500), new(CellonType.HpRecovery, 110, 135), new(CellonType.MpRecovery, 110, 135), new(CellonType.MpConsumption, 14, 21), new(CellonType.CriticalDamageDecrease, 22, 45) }, + new CellonOption[] { new(CellonType.Hp, 1200, 1700), new(CellonType.Mp, 1200, 1700), new(CellonType.HpRecovery, 120, 150), new(CellonType.MpRecovery, 120, 150), new(CellonType.MpConsumption, 15, 25), new(CellonType.CriticalDamageDecrease, 23, 55) }, + }; + + public override UpgradePacketType Kind => UpgradePacketType.CellonItem; + + protected override Game18NConstString SuccessMessage => Game18NConstString.UpgradeSuccessful; + + protected override Game18NConstString FailureMessage => Game18NConstString.CellonDisapearedFailedUpgrade; + + protected override UpgradeContext? TryPrepareContext(ClientSession session, UpgradePacket packet) + { + if (packet.CellonInventoryType is null || packet.CellonSlot is null) + { + return null; + } + + var jewelSlot = session.Character.InventoryService + .LoadBySlotAndType(packet.Slot, (NoscorePocketType)packet.InventoryType); + var cellonSlot = session.Character.InventoryService + .LoadBySlotAndType(packet.CellonSlot.Value, (NoscorePocketType)packet.CellonInventoryType.Value); + + if (jewelSlot?.ItemInstance is not WearableInstance jewel || cellonSlot?.ItemInstance is null) + { + return null; + } + + var level = cellonSlot.ItemInstance.Item.EffectValue; + if (level <= 0 || level >= OptionsByCellonLevel.Length || level > jewel.Item.MaxCellonLvl) + { + return null; + } + + var applied = jewel.Cellon ?? 0; + if (applied >= jewel.Item.MaxCellon || applied >= SuccessRateByOptionCount.Length) + { + return null; + } + + var jewelId = jewelSlot.ItemInstanceId; + var taken = equipmentOptionDao.Where(o => o.WearableInstanceId == jewelId)? + .Select(o => o.Type).ToHashSet() ?? new HashSet(); + var candidates = OptionsByCellonLevel[level] + .Where(o => !taken.Contains((byte)o.Type)) + .ToArray(); + + return new UpgradeContext( + Source: jewelSlot, + Target: cellonSlot, + GoldCost: GoldCostByCellonLevel[level], + MaterialCosts: Array.Empty(), + ExtraData: new CellonRollData(level, applied, candidates)); + } + + // A jewel holding every option its tier offers has nothing left to roll, so the attempt + // fails outright rather than reporting a success that adds nothing. + protected override UpgradeOutcome DetermineOutcome(double roll, UpgradeContext ctx) => + ((CellonRollData)ctx.ExtraData!).Candidates.Length == 0 + ? UpgradeOutcome.Failure + : base.DetermineOutcome(roll, ctx); + + protected override double GetSuccessRate(UpgradeContext ctx) => + SuccessRateByOptionCount[((CellonRollData)ctx.ExtraData!).AppliedCount]; + + protected override void ApplySuccess(UpgradeContext ctx) + { + var data = (CellonRollData)ctx.ExtraData!; + var jewel = (WearableInstance)ctx.Source.ItemInstance!; + var option = data.Candidates[Roll(data.Candidates.Length)]; + + data.Rolled = new EquipmentOptionDto + { + Id = Guid.NewGuid(), + WearableInstanceId = ctx.Source.ItemInstanceId, + Level = (byte)data.Level, + Type = (byte)option.Type, + Value = option.Minimum + Roll(option.Maximum - option.Minimum + 1), + }; + jewel.Cellon = (byte)(data.AppliedCount + 1); + } + + // The cellon is destroyed either way, so a failed roll leaves the jewel untouched. + protected override void ApplyFailure(ClientSession session, UpgradeContext ctx) { } + + protected override void ConsumeFixedSlots(ClientSession session, UpgradeContext ctx) + { + session.Character.InventoryService.RemoveItemAmountFromInventory(1, ctx.Target!.ItemInstanceId); + } + + protected override async Task EmitOutcomeEffectsAsync(ClientSession session, UpgradeContext ctx, + UpgradeOutcome outcome, List playerPackets) + { + var rolled = ((CellonRollData)ctx.ExtraData!).Rolled; + if (rolled is not null) + { + await equipmentOptionDao.TryInsertOrUpdateAsync(rolled); + } + } + + protected override IEnumerable BuildPocketRefresh(UpgradeContext ctx, UpgradeOutcome outcome) + { + yield return ((InventoryItemInstance?)null).GeneratePocketChange( + (PocketType)ctx.Target!.Type, ctx.Target.Slot); + yield return ctx.Source.GeneratePocketChange((PocketType)ctx.Source.Type, ctx.Source.Slot); + } + + private sealed record CellonOption(CellonType Type, int Minimum, int Maximum); + + private sealed class CellonRollData(int level, int appliedCount, CellonOption[] candidates) + { + public int Level { get; } = level; + + public int AppliedCount { get; } = appliedCount; + + public CellonOption[] Candidates { get; } = candidates; + + public EquipmentOptionDto? Rolled { get; set; } + } +} diff --git a/src/NosCore.GameObject/Services/UpgradeService/UpgradeOperation.cs b/src/NosCore.GameObject/Services/UpgradeService/UpgradeOperation.cs index 0daf9da59..193e73b2f 100644 --- a/src/NosCore.GameObject/Services/UpgradeService/UpgradeOperation.cs +++ b/src/NosCore.GameObject/Services/UpgradeService/UpgradeOperation.cs @@ -211,6 +211,11 @@ private static void ConsumeMaterials(ClientSession session, UpgradeContext ctx, } } + // Uniform integer in [0, exclusiveUpperBound) for operations that need to roll more than the + // single success check the skeleton performs. + protected int Roll(int exclusiveUpperBound) => + Math.Min((int)(random.NextDouble() * exclusiveUpperBound), exclusiveUpperBound - 1); + protected virtual SayiPacket BuildSay(ClientSession session, UpgradeContext ctx, UpgradeOutcome outcome, Game18NConstString message) => new() { diff --git a/test/NosCore.GameObject.Tests/Services/UpgradeService/CellonOperationTests.cs b/test/NosCore.GameObject.Tests/Services/UpgradeService/CellonOperationTests.cs new file mode 100644 index 000000000..9ee541d8c --- /dev/null +++ b/test/NosCore.GameObject.Tests/Services/UpgradeService/CellonOperationTests.cs @@ -0,0 +1,239 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using NosCore.Dao.Interfaces; +using NosCore.Data.Dto; +using NosCore.Data.Enumerations; +using NosCore.Data.Enumerations.Items; +using NosCore.GameObject.Networking.ClientSession; +using NosCore.GameObject.Services.InventoryService; +using NosCore.GameObject.Services.ItemGenerationService.Item; +using NosCore.GameObject.Services.UpgradeService; +using NosCore.Packets.ClientPackets.Player; +using NosCore.Packets.Enumerations; +using NosCore.Packets.Interfaces; +using NosCore.Tests.Shared; +using SpecLight; + +namespace NosCore.GameObject.Tests.Services.UpgradeService +{ + // Cellon rolls are driven by a single stubbed NextDouble, so a roll of 0.10 at a level-1 + // cellon picks the first option (Hp, 30..100) and lands 10% into its range. + [TestClass] + public class CellonOperationTests + { + private const short JewelVNum = 900; + private const short CellonVNum = 1017; + + private ClientSession _session = null!; + private Mock _random = null!; + private Mock> _optionDao = null!; + private List _existingOptions = null!; + private List _persisted = null!; + private CellonOperation _operation = null!; + private InventoryItemInstance _jewel = null!; + private InventoryItemInstance _cellon = null!; + private IReadOnlyList? _result; + + [TestInitialize] + public async Task SetupAsync() + { + await TestHelpers.ResetAsync(); + _session = await TestHelpers.Instance.GenerateSessionAsync(); + _random = new Mock(); + _existingOptions = new List(); + _persisted = new List(); + + _optionDao = new Mock>(); + _optionDao.Setup(d => d.Where(It.IsAny>>())) + .Returns(() => _existingOptions); + _optionDao.Setup(d => d.TryInsertOrUpdateAsync(It.IsAny())) + .Returns((EquipmentOptionDto dto) => + { + _persisted.Add(dto); + return Task.FromResult(dto); + }); + + _operation = new CellonOperation(_random.Object, TestHelpers.Instance.GameLanguageLocalizer, + _optionDao.Object); + } + + [TestMethod] + public async Task SuccessAddsAnOptionAndChargesTheCellonAndGold() + { + await new Spec("A successful level-1 cellon adds one option, bumps the counter and charges 700 gold") + .Given(JewelWith_, (byte)0) + .And(CellonOfLevel_, 1) + .And(CharacterHasGold_, 100_000L) + .And(NextRollWillBe_, 0.10) + .WhenAsync(CellonIsApplied) + .Then(PersistedOptionCountShouldBe_, 1) + .And(PersistedOptionTypeShouldBe_, CellonType.Hp) + .And(PersistedOptionValueShouldBe_, 37) + .And(JewelCellonCountShouldBe_, (byte)1) + .And(GoldShouldBe_, 99_300L) + .And(CellonSlotShouldBeEmpty) + .ExecuteAsync(); + } + + [TestMethod] + public async Task FailureConsumesTheCellonWithoutAddingAnOption() + { + await new Spec("A failed roll still burns the cellon and the gold but leaves the jewel untouched") + .Given(JewelWith_, (byte)0) + .And(CellonOfLevel_, 1) + .And(CharacterHasGold_, 100_000L) + .And(NextRollWillBe_, 0.90) + .WhenAsync(CellonIsApplied) + .Then(PersistedOptionCountShouldBe_, 0) + .And(JewelCellonCountShouldBe_, (byte)0) + .And(GoldShouldBe_, 99_300L) + .And(CellonSlotShouldBeEmpty) + .ExecuteAsync(); + } + + [TestMethod] + public async Task CellonAboveTheJewelTierIsRejected() + { + await new Spec("A cellon stronger than the jewel accepts is refused before anything is charged") + .Given(JewelWith_, (byte)0) + .And(CellonOfLevel_, 5) + .And(CharacterHasGold_, 100_000L) + .And(NextRollWillBe_, 0.10) + .WhenAsync(CellonIsApplied) + .Then(NoPacketsShouldBeReturned) + .And(GoldShouldBe_, 100_000L) + .And(PersistedOptionCountShouldBe_, 0) + .ExecuteAsync(); + } + + [TestMethod] + public async Task FullJewelIsRejected() + { + await new Spec("A jewel already holding its maximum options is refused") + .Given(JewelWith_, (byte)2) + .And(CellonOfLevel_, 1) + .And(CharacterHasGold_, 100_000L) + .And(NextRollWillBe_, 0.10) + .WhenAsync(CellonIsApplied) + .Then(NoPacketsShouldBeReturned) + .And(GoldShouldBe_, 100_000L) + .ExecuteAsync(); + } + + [TestMethod] + public async Task ExhaustedOptionTypesFailInsteadOfReportingSuccess() + { + await new Spec("When every option the tier offers is already on the jewel the attempt fails") + .Given(JewelWith_, (byte)1) + .And(CellonOfLevel_, 1) + .And(AllLevelOneOptionsAlreadyTaken) + .And(CharacterHasGold_, 100_000L) + .And(NextRollWillBe_, 0.10) + .WhenAsync(CellonIsApplied) + .Then(PersistedOptionCountShouldBe_, 0) + .And(JewelCellonCountShouldBe_, (byte)1) + .ExecuteAsync(); + } + + // --- Givens --- + + private void JewelWith_(byte appliedOptions) + { + var item = new Item + { + VNum = JewelVNum, + Type = NoscorePocketType.Equipment, + ItemType = ItemType.Jewelery, + MaxCellon = 2, + MaxCellonLvl = 1, + }; + var wearable = new WearableInstance(item, new Mock>().Object, + TestHelpers.Instance.LogLanguageLocalizer) + { + Cellon = appliedOptions, + }; + _jewel = InventoryItemInstance.Create(wearable, _session.Character.CharacterId); + _jewel.Slot = 0; + _jewel.Type = NoscorePocketType.Equipment; + _session.Character.InventoryService[_jewel.ItemInstanceId] = _jewel; + } + + private void CellonOfLevel_(int level) + { + var instance = new CellonItemForTest(CellonVNum, level) { Amount = 1 }; + _cellon = InventoryItemInstance.Create(instance, _session.Character.CharacterId); + _cellon.Slot = 3; + _cellon.Type = NoscorePocketType.Main; + _session.Character.InventoryService[_cellon.ItemInstanceId] = _cellon; + } + + private void AllLevelOneOptionsAlreadyTaken() => + _existingOptions.AddRange(new[] { CellonType.Hp, CellonType.Mp, CellonType.HpRecovery, CellonType.MpRecovery } + .Select(t => new EquipmentOptionDto { Type = (byte)t, WearableInstanceId = _jewel.ItemInstanceId })); + + private void CharacterHasGold_(long gold) => _session.Character.Gold = gold; + + private void NextRollWillBe_(double roll) => _random.Setup(r => r.NextDouble()).Returns(roll); + + // --- Whens --- + + private async Task CellonIsApplied() => _result = await _operation.ExecuteAsync(_session, new UpgradePacket + { + UpgradeType = UpgradePacketType.CellonItem, + InventoryType = PocketType.Equipment, + Slot = 0, + CellonInventoryType = PocketType.Main, + CellonSlot = 3, + }); + + // --- Thens --- + + private void PersistedOptionCountShouldBe_(int expected) => + Assert.AreEqual(expected, _persisted.Count); + + private void PersistedOptionTypeShouldBe_(CellonType expected) => + Assert.AreEqual((byte)expected, _persisted[0].Type); + + private void PersistedOptionValueShouldBe_(int expected) => + Assert.AreEqual(expected, _persisted[0].Value); + + private void JewelCellonCountShouldBe_(byte expected) => + Assert.AreEqual(expected, ((WearableInstance)_jewel.ItemInstance!).Cellon ?? 0); + + private void GoldShouldBe_(long expected) => Assert.AreEqual(expected, _session.Character.Gold); + + private void CellonSlotShouldBeEmpty() => + Assert.IsNull(_session.Character.InventoryService + .LoadBySlotAndType(3, NoscorePocketType.Main)); + + private void NoPacketsShouldBeReturned() => Assert.AreEqual(0, _result!.Count); + + private sealed class CellonItemForTest(short vnum, int effectValue) : ItemInstanceDto, IItemInstance + { + public new Guid Id { get; set; } = Guid.NewGuid(); + + public new short ItemVNum { get; set; } = vnum; + + public Item Item { get; set; } = new() + { + VNum = vnum, + Type = NoscorePocketType.Main, + EffectValue = effectValue, + }; + + public object Clone() => MemberwiseClone(); + } + } +} From d197924a02fafe2bc40a256ae985fd5b403d57c9 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Mon, 31 Aug 2026 20:47:43 +1200 Subject: [PATCH 2/2] fix: cellon tier never reached the item, leaving every cellon unusable ImportEffectValue maps DATA by ItemType and had no case for ItemType.Upgrade, so cellons (vnum 1017-1026) parsed with EffectValue 0. CellonOperation reads that value as the option tier and rejects anything at or below zero, so no cellon could ever be applied. Item.dat carries the tier in the third DATA value, matching vnum - 1016 across all ten cellons. Every other upgrade material has an all-zero DATA row, so reading it there leaves them at 0 as before. Co-Authored-By: Claude Opus 5 (1M context) --- src/NosCore.Parser/Parsers/ItemParser.cs | 2 + test/NosCore.Parser.Tests/ItemParserTests.cs | 57 ++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/NosCore.Parser/Parsers/ItemParser.cs b/src/NosCore.Parser/Parsers/ItemParser.cs index ab916eaab..4d8e36d71 100644 --- a/src/NosCore.Parser/Parsers/ItemParser.cs +++ b/src/NosCore.Parser/Parsers/ItemParser.cs @@ -252,6 +252,8 @@ ItemType.Special when ImportEffect(chunk) == ItemEffectType.ApplySkinPartner => ItemType.Event => chunk["DATA"][0][7], ItemType.Magical => chunk["DATA"][0][4], ItemType.Production => chunk["DATA"][0][4], + // Cellons carry the option tier here; every other upgrade material leaves it 0. + ItemType.Upgrade => chunk["DATA"][0][4], ItemType.Map => chunk["DATA"][0][4], ItemType.Main => chunk["DATA"][0][4], ItemType.Teacher => chunk["DATA"][0][4], diff --git a/test/NosCore.Parser.Tests/ItemParserTests.cs b/test/NosCore.Parser.Tests/ItemParserTests.cs index ef681f9bb..8505f95ff 100644 --- a/test/NosCore.Parser.Tests/ItemParserTests.cs +++ b/test/NosCore.Parser.Tests/ItemParserTests.cs @@ -436,5 +436,62 @@ public async Task ItemParser_ConsumableLevelMinimumFallbackIsZeroEvenWhenDataHas Assert.AreEqual(1, _savedItems.Count); Assert.AreEqual(0, _savedItems[0].LevelMinimum); } + + // Cellons (vnum 1017-1026) are upgrade materials whose option tier sits in the third + // DATA value; without it every cellon looks like tier 0 and can never be applied. + [TestMethod] + public async Task ItemParser_CellonTierIsReadFromData() + { + CreateTestFile(CreateItemData( + vnum: 1022, + price: 3000, + indexType: 1, + indexSubType: 1, + indexItemType: 0, + equipmentSlot: 0, + data: "100\t0\t6\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0")); + + var parser = new ItemParser(_itemDaoMock.Object, _bCardDaoMock.Object, NullLoggerFactory.Instance, _logLanguageMock.Object); + await parser.ParseAsync(_tempFolder); + + Assert.AreEqual(ItemType.Upgrade, _savedItems[0].ItemType); + Assert.AreEqual(6, _savedItems[0].EffectValue); + } + + [TestMethod] + public async Task ItemParser_OtherUpgradeMaterialsKeepNoEffectValue() + { + CreateTestFile(CreateItemData( + vnum: 1014, + indexType: 1, + indexSubType: 1, + indexItemType: 0, + equipmentSlot: 0)); + + var parser = new ItemParser(_itemDaoMock.Object, _bCardDaoMock.Object, NullLoggerFactory.Instance, _logLanguageMock.Object); + await parser.ParseAsync(_tempFolder); + + Assert.AreEqual(ItemType.Upgrade, _savedItems[0].ItemType); + Assert.AreEqual(0, _savedItems[0].EffectValue); + } + + // A jewel declares how many options it holds and the strongest tier it accepts. + [TestMethod] + public async Task ItemParser_JewelDeclaresItsCellonCapacity() + { + CreateTestFile(CreateItemData( + vnum: 303, + indexType: 0, + indexSubType: 3, + indexItemType: 0, + equipmentSlot: (int)EquipmentType.Necklace, + data: "62\t6\t2\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0")); + + var parser = new ItemParser(_itemDaoMock.Object, _bCardDaoMock.Object, NullLoggerFactory.Instance, _logLanguageMock.Object); + await parser.ParseAsync(_tempFolder); + + Assert.AreEqual(6, _savedItems[0].MaxCellonLvl); + Assert.AreEqual(2, _savedItems[0].MaxCellon); + } } }