using System; using System.Collections.Generic; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using LLib.GameData; using LLib.Inventory; using LLib.Shop; using Lumina.Excel; using Lumina.Excel.Sheets; using Microsoft.Extensions.Logging; using Questionable.External; namespace Questionable.Controller.CustomDelivery; internal sealed class DeliveryPlannerService { private readonly SatisfactionSupplyHelper _supplyHelper; private readonly VendorResolverService _vendorResolver; private readonly DeliveryRewardCalculator _rewardCalculator; private readonly IDataManager _dataManager; private readonly Configuration _configuration; private readonly ILogger _logger; public DeliveryPlannerService(SatisfactionSupplyHelper supplyHelper, VendorResolverService vendorResolver, DeliveryRewardCalculator rewardCalculator, IDataManager dataManager, Configuration configuration, ILogger logger) { _supplyHelper = supplyHelper; _vendorResolver = vendorResolver; _rewardCalculator = rewardCalculator; _dataManager = dataManager; _configuration = configuration; _logger = logger; } public unsafe DeliveryPlan? CreatePlan(int npcIndex, EDeliverySlot slot) { if (!InventoryHelper.HasFreeInventorySlot()) { _logger.LogWarning("No free inventory slots, cannot plan custom delivery"); return null; } if (!IsNpcUnlocked(npcIndex)) { _logger.LogDebug("NPC {NpcIndex} prerequisite quest not complete", npcIndex); return null; } SatisfactionSupplyActions.NpcState npcState = SatisfactionSupplyActions.GetNpcState(npcIndex); int remainingAllowances = SatisfactionSupplyActions.GetRemainingAllowances(); uint supplySeed = SatisfactionSupplyActions.GetSupplySeed(); if (npcState.Rank <= 0) { _logger.LogDebug("NPC {NpcIndex} is locked (rank {Rank})", npcIndex, npcState.Rank); return null; } if (remainingAllowances <= 0) { _logger.LogDebug("No remaining weekly allowances"); return null; } if (!_dataManager.GetExcelSheet().TryGetRow((uint)(npcIndex + 1), out var row)) { _logger.LogWarning("SatisfactionNpc row not found for index {NpcIndex}", npcIndex); return null; } ushort levelUnlock = row.LevelUnlock; if (!HasJobAtLevel(slot, levelUnlock)) { _logger.LogDebug("No job at level {Level} for slot {Slot}, NPC {NpcIndex}", levelUnlock, slot, npcIndex); return null; } uint supplyIndex = (uint)row.SatisfactionNpcParams[npcState.Rank].SupplyIndex; SubrowExcelSheet subrowExcelSheet = _dataManager.GetSubrowExcelSheet(); if (TryGetLiveSupplyRow(npcIndex, slot, supplyIndex, out var supplyRow, out var hasLiveRequest)) { _logger.LogDebug("Using live requested item {ItemId} for NPC {NpcIndex} slot {Slot}", supplyRow.Item.RowId, npcIndex, slot); } else { if (hasLiveRequest) { return null; } uint num = _supplyHelper.CalculateRequestedItems(supplyIndex, supplySeed)[(int)slot]; if (num == uint.MaxValue) { _logger.LogDebug("No item predicted for NPC {NpcIndex} slot {Slot}", npcIndex, slot); return null; } if (!subrowExcelSheet.TryGetSubrow(supplyIndex, (ushort)num, out supplyRow)) { _logger.LogWarning("SatisfactionSupply subrow ({SupplyIndex}, {SubrowIndex}) not found", supplyIndex, num); return null; } } uint rowId = supplyRow.Item.RowId; ushort num2 = _configuration.CustomDeliveries.CollectabilityTier switch { Configuration.CustomDeliveryConfiguration.ECollectabilityTier.Low => supplyRow.CollectabilityLow, Configuration.CustomDeliveryConfiguration.ECollectabilityTier.Mid => supplyRow.CollectabilityMid, _ => supplyRow.CollectabilityHigh, }; if (rowId == 0) { _logger.LogWarning("SatisfactionSupply row {SupplyIndex} has no item for slot {Slot}", supplyIndex, slot); return null; } int num3 = row.DeliveriesPerWeek - npcState.UsedDeliveries; int num4 = Math.Min(num3, remainingAllowances); if (num4 <= 0) { _logger.LogDebug("No deliveries available for NPC {NpcIndex} (npc remaining: {NpcRemaining}, global remaining: {GlobalRemaining})", npcIndex, num3, remainingAllowances); return null; } int satisfactionRequired = row.SatisfactionNpcParams[npcState.Rank].SatisfactionRequired; if (satisfactionRequired > 0) { Configuration.CustomDeliveryConfiguration.ECollectabilityTier collectabilityTier = _configuration.CustomDeliveries.CollectabilityTier; int num5 = _rewardCalculator.CalculateSatisfactionPerDelivery(supplyRow.Reward.RowId, supplyRow.IsBonus, collectabilityTier); if (num5 > 0) { int num6 = (satisfactionRequired - npcState.SatisfactionCurrent + num5 - 1) / num5; if (num6 > 0 && num6 < num4) { _logger.LogDebug("Capping deliveries from {Original} to {Capped} to avoid rank-up mid-batch (sat: {Current}/{Required}, per delivery: {PerDelivery})", num4, num6, npcState.SatisfactionCurrent, satisfactionRequired, num5); num4 = num6; } } } ushort key = (ushort)(npcIndex + 1); if (_configuration.CustomDeliveries.AchievementGoals.TryGetValue(key, out Configuration.AchievementGoal value) && value.Active) { int num7 = (int)value.TargetCount - npcState.UsedDeliveries; if (num7 <= 0) { _logger.LogDebug("Achievement goal already met for NPC {NpcIndex} (used: {Used}, target: {Target})", npcIndex, npcState.UsedDeliveries, value.TargetCount); return null; } num4 = Math.Min(num4, num7); } if (_configuration.CustomDeliveries.ScripOvercapMode != Configuration.CustomDeliveryConfiguration.EScripOvercapMode.Ignore) { uint rowId2 = supplyRow.Reward.RowId; bool isBonus = supplyRow.IsBonus; int num8 = _rewardCalculator.MaxDeliveriesBeforeOvercap(rowId2, isBonus, _configuration.CustomDeliveries.CollectabilityTier); if (num8 <= 0) { _logger.LogDebug("Scrip overcap: blocking delivery for NPC {NpcIndex}", npcIndex); return null; } if (num8 < num4) { _logger.LogDebug("Scrip overcap: capping delivery count from {Original} to {Capped} for NPC {NpcIndex}", num4, num8, npcIndex); num4 = num8; } } VendorResolverService.RecipeInfo? recipe = null; List list = new List(); if (slot == EDeliverySlot.Craft) { recipe = _vendorResolver.ResolveRecipe(rowId); if (!recipe.HasValue) { _logger.LogError("Cannot resolve recipe for craft item {ItemId}", rowId); return null; } int inventoryItemCount = InventoryManager.Instance()->GetInventoryItemCount(rowId, isHq: false, checkEquipped: true, checkArmory: true, (short)num2); int num9 = Math.Max(0, num4 - inventoryItemCount); foreach (VendorResolverService.IngredientInfo ingredient in recipe.Value.Ingredients) { int num10 = num9 * ingredient.CountPerCraft; int count = ((ItemHandle)ingredient.ItemId).GetCount(); int num11 = Math.Max(0, num10 - count); VendorResolverService.VendorInfo? vendor = null; if (num11 > 0) { vendor = _vendorResolver.ResolveVendor(ingredient.ItemId); } if (num11 > 0 && !vendor.HasValue) { _logger.LogWarning("Cannot plan craft delivery for item {ItemId}: missing {ToBuy}x ingredient {IngredientId} with no gil vendor", rowId, num11, ingredient.ItemId); return null; } list.Add(new IngredientPlan { ItemId = ingredient.ItemId, TotalNeeded = num10, InInventory = count, ToBuy = num11, Vendor = vendor }); } } DeliveryPlan result = new DeliveryPlan { NpcIndex = npcIndex, Slot = slot, ItemId = rowId, Collectability = num2, DeliveryCount = num4, Recipe = recipe, IngredientPlans = list }; int num12 = 0; foreach (IngredientPlan item in list) { num12 += item.ToBuy; } _logger.LogDebug("Delivery plan: NPC {NpcIndex} slot {Slot}, item {ItemId} x{Count}, ingredients to buy: {ToBuy}", npcIndex, slot, rowId, num4, num12); return result; } private unsafe bool TryGetLiveSupplyRow(int npcIndex, EDeliverySlot slot, uint supplyIndex, out SatisfactionSupply supplyRow, out bool hasLiveRequest) { hasLiveRequest = false; AgentSatisfactionSupply* ptr = AgentSatisfactionSupply.Instance(); if (ptr != null && ptr->IsAgentActive() && ptr->NpcInfo.Valid && ptr->NpcInfo.Initialized && ptr->NpcInfo.Id == (uint)(npcIndex + 1)) { uint id = ptr->Items[(int)slot].Id; if (id != 0) { hasLiveRequest = true; foreach (SatisfactionSupply item in _dataManager.GetSubrowExcelSheet().Flatten()) { if (item.RowId == supplyIndex && item.Item.RowId == id) { supplyRow = item; return true; } } _logger.LogWarning("Live requested item {ItemId} was not found in SatisfactionSupply row {SupplyIndex}", id, supplyIndex); } } supplyRow = default(SatisfactionSupply); return false; } public bool IsNpcUnlocked(int npcIndex) { if (!_dataManager.GetExcelSheet().TryGetRow((uint)(npcIndex + 1), out var row)) { return false; } uint rowId = row.QuestRequired.RowId; if (rowId == 0) { return true; } return QuestManager.IsQuestComplete((ushort)rowId); } private bool HasJobAtLevel(EDeliverySlot slot, ushort requiredLevel) { ExcelSheet excelSheet = _dataManager.GetExcelSheet(); EClassJob[] array = slot switch { EDeliverySlot.Craft => new EClassJob[8] { EClassJob.Carpenter, EClassJob.Blacksmith, EClassJob.Armorer, EClassJob.Goldsmith, EClassJob.Leatherworker, EClassJob.Weaver, EClassJob.Alchemist, EClassJob.Culinarian }, EDeliverySlot.Gather => new EClassJob[2] { EClassJob.Miner, EClassJob.Botanist }, EDeliverySlot.Fish => new EClassJob[1] { EClassJob.Fisher }, _ => Array.Empty(), }; foreach (EClassJob rowId in array) { if (excelSheet.TryGetRow((uint)rowId, out var row) && PlayerStateHelper.GetClassJobLevel(row.ExpArrayIndex) >= requiredLevel) { return true; } } return false; } public EDeliverySlot? AutoSelectSlot(int npcIndex, ArtisanIpc artisanIpc, AutoHookIpc autoHookIpc) { if (artisanIpc.IsAvailable() && CreatePlan(npcIndex, EDeliverySlot.Craft) != null) { return EDeliverySlot.Craft; } if (CreatePlan(npcIndex, EDeliverySlot.Gather) != null) { return EDeliverySlot.Gather; } if (autoHookIpc.IsAvailable() && CreatePlan(npcIndex, EDeliverySlot.Fish) != null) { return EDeliverySlot.Fish; } return null; } }