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/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.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(); + } + } +} 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); + } } }