diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4ac68e0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "LLib"] + path = LLib + url = https://git.carvel.li/liza/LLib.git diff --git a/LLib b/LLib new file mode 160000 index 0000000..e59d291 --- /dev/null +++ b/LLib @@ -0,0 +1 @@ +Subproject commit e59d291f04473eae0b76712397733e2e25349953 diff --git a/Squadronista.sln b/Squadronista.sln index c707ee5..eaab31d 100644 --- a/Squadronista.sln +++ b/Squadronista.sln @@ -2,6 +2,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squadronista", "Squadronista\Squadronista.csproj", "{71E7BF47-88EE-48F4-8FBE-F89F20F34F38}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LLib", "LLib\LLib.csproj", "{5D61CA78-142C-4DC8-A780-268DB96D8D5B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +14,9 @@ Global {71E7BF47-88EE-48F4-8FBE-F89F20F34F38}.Debug|Any CPU.Build.0 = Debug|Any CPU {71E7BF47-88EE-48F4-8FBE-F89F20F34F38}.Release|Any CPU.ActiveCfg = Release|Any CPU {71E7BF47-88EE-48F4-8FBE-F89F20F34F38}.Release|Any CPU.Build.0 = Release|Any CPU + {5D61CA78-142C-4DC8-A780-268DB96D8D5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D61CA78-142C-4DC8-A780-268DB96D8D5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D61CA78-142C-4DC8-A780-268DB96D8D5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D61CA78-142C-4DC8-A780-268DB96D8D5B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Squadronista/Solver/Attributes.cs b/Squadronista/Solver/Attributes.cs new file mode 100644 index 0000000..8296e05 --- /dev/null +++ b/Squadronista/Solver/Attributes.cs @@ -0,0 +1,8 @@ +namespace Squadronista.Solver; + +internal class Attributes +{ + public required int PhysicalAbility { get; init; } + public required int MentalAbility { get; init; } + public required int TacticalAbility { get; init; } +} diff --git a/Squadronista/Solver/BonusAttributes.cs b/Squadronista/Solver/BonusAttributes.cs index ac1c279..3eb938e 100644 --- a/Squadronista/Solver/BonusAttributes.cs +++ b/Squadronista/Solver/BonusAttributes.cs @@ -6,11 +6,8 @@ namespace Squadronista.Solver; /// Bonus stats from training, these apply to the whole squadron (e.g. if you have 120/80/80, and no members selected, /// the mission would be at 120/80/80). /// -internal sealed class BonusAttributes : IEquatable +internal sealed class BonusAttributes : Attributes, IEquatable { - public required int PhysicalAbility { get; init; } - public required int MentalAbility { get; init; } - public required int TacticalAbility { get; init; } public required int Cap { get; init; } public BonusAttributes ApplyTraining(Training training) diff --git a/Squadronista/Solver/SquadronMember.cs b/Squadronista/Solver/SquadronMember.cs index 3969d01..774d6ae 100644 --- a/Squadronista/Solver/SquadronMember.cs +++ b/Squadronista/Solver/SquadronMember.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Dalamud.Plugin.Services; @@ -5,7 +6,7 @@ using Lumina.Excel.GeneratedSheets; namespace Squadronista.Solver; -internal sealed class SquadronMember +internal sealed class SquadronMember : IEquatable { public required string Name { get; init; } public required int Level { get; init; } @@ -37,4 +38,31 @@ internal sealed class SquadronMember GrowthParams = growthAsList; return this; } + + public bool Equals(SquadronMember? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Name == other.Name && Level == other.Level && ClassJob == other.ClassJob && Race == other.Race && Experience == other.Experience; + } + + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) || obj is SquadronMember other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Name, Level, ClassJob, (int)Race, Experience); + } + + public static bool operator ==(SquadronMember? left, SquadronMember? right) + { + return Equals(left, right); + } + + public static bool operator !=(SquadronMember? left, SquadronMember? right) + { + return !Equals(left, right); + } } diff --git a/Squadronista/Solver/SquadronSolver.cs b/Squadronista/Solver/SquadronSolver.cs index 6cc8aad..3d7e376 100644 --- a/Squadronista/Solver/SquadronSolver.cs +++ b/Squadronista/Solver/SquadronSolver.cs @@ -38,21 +38,21 @@ internal sealed class SquadronSolver _newTrainings = _calculatedTrainings.Keys.ToList(); } - public IEnumerable SolveFor(SquadronMission mission) + public IEnumerable SolveFor(SquadronMission mission, int requiredMatchingStats) { - int minPhysical = mission.PhysicalAbility; - int minMental = mission.MentalAbility; - int minTactical = mission.TacticalAbility; + int minPhysical = mission.CurrentAttributes.PhysicalAbility; + int minMental = mission.CurrentAttributes.MentalAbility; + int minTactical = mission.CurrentAttributes.TacticalAbility; bool foundWithoutTraining = false; List intermediates = CalculateForAllMemberCombinations(mission.Level, _state.Bonus); foreach (var x in intermediates) { - //_pluginLog.Information($"{string.Join(" ", x.Members.Select(y => y.Name))} → {x.PhysicalAbility} / {x.MentalAbility} / {x.TacticalAbility}"); - if (x.PhysicalAbility >= minPhysical && x.MentalAbility >= minMental && x.TacticalAbility >= minTactical) + int matchingStats = CountStatMatches(x, minPhysical, minMental, minTactical); + if (matchingStats >= requiredMatchingStats) { x.TrainingsCalculated = true; - yield return x.WithMission(mission); + yield return x.WithExtra(mission, matchingStats); foundWithoutTraining = true; } @@ -65,19 +65,26 @@ internal sealed class SquadronSolver intermediates = CalculateForAllMemberCombinations(mission.Level, bonus); foreach (var x in intermediates) { - if (x.PhysicalAbility >= minPhysical && x.MentalAbility >= minMental && - x.TacticalAbility >= minTactical) + int matchingStats = CountStatMatches(x, minPhysical, minMental, minTactical); + if (matchingStats >= requiredMatchingStats) { CalculateTrainingsForBonus(x); if (x.TrainingsCalculated) - yield return x.WithMission(mission); + yield return x.WithExtra(mission, matchingStats); } } } } } + private int CountStatMatches(CalculationResult x, int minPhysical, int minMental, int minTactical) + { + return (x.PhysicalAbility >= minPhysical ? 1 : 0) + + (x.MentalAbility >= minMental ? 1 : 0) + + (x.TacticalAbility >= minTactical ? 1 : 0); + } + private List CalculateForAllMemberCombinations(int requiredLevel, BonusAttributes bonus) { return _memberCombinations @@ -131,7 +138,7 @@ internal sealed class SquadronSolver if (_calculatedTrainings.TryGetValue(result.Bonus, out var calculatedTraining)) { - _pluginLog.Information($"Found existing steps: {string.Join(", ", calculatedTraining.Select(x => x.Name))}"); + //_pluginLog.Information($"Found existing steps: {string.Join(", ", calculatedTraining.Select(x => x.Name))}"); result.Trainings = calculatedTraining; result.TrainingsCalculated = true; return; @@ -182,6 +189,8 @@ internal sealed class SquadronSolver public sealed class CalculationResult { public SquadronMission? Mission { get; private set; } + public int MatchingAttributes { get; private set; } + public int PhysicalAbility { get; } public int MentalAbility { get; } public int TacticalAbility { get; } @@ -189,6 +198,7 @@ internal sealed class SquadronSolver public BonusAttributes Bonus { get; } public IReadOnlyList Trainings { get; set; } = new List().AsReadOnly(); public bool TrainingsCalculated { get; set; } + public int TotalLevel => Members.Sum(x => x.Level); public CalculationResult(List members, BonusAttributes bonus) { @@ -199,10 +209,27 @@ internal sealed class SquadronSolver Bonus = bonus; } - public CalculationResult WithMission(SquadronMission mission) + public CalculationResult WithExtra(SquadronMission mission, int matchingAttributes) { Mission = mission; + MatchingAttributes = matchingAttributes; return this; } + + public int ToSuccessProbability() => MatchingAttributes == 3 ? 100 : 66; + + public string ToLabel() + { + if (Trainings.Count == 0) + return $"{ToSuccessProbability()}%%, no training"; + else + return $"{ToSuccessProbability()}%%"; + } + } + + public sealed class CalculationResults + { + public bool IsFlaggedMission { get; set; } + public List Results { get; } = new(); } } diff --git a/Squadronista/Solver/SquadronState.cs b/Squadronista/Solver/SquadronState.cs index 4eb50b4..b0ed1fe 100644 --- a/Squadronista/Solver/SquadronState.cs +++ b/Squadronista/Solver/SquadronState.cs @@ -5,9 +5,15 @@ namespace Squadronista.Solver; internal sealed class SquadronState { + private readonly Dictionary> _calculationResults = new(); + public required byte Rank { get; init; } public required IReadOnlyList Members { get; init; } public required BonusAttributes Bonus { get; set; } - public Task? CalculationTask { get; set; } + public Task? GetCalculation(SquadronMission mission) + => _calculationResults.TryGetValue(mission, out var task) ? task : null; + + public void SetCalculation(SquadronMission mission, Task task) + => _calculationResults[mission] = task; } diff --git a/Squadronista/SquadronMission.cs b/Squadronista/SquadronMission.cs index 954470a..af8b76e 100644 --- a/Squadronista/SquadronMission.cs +++ b/Squadronista/SquadronMission.cs @@ -1,4 +1,6 @@ -namespace Squadronista; +using Squadronista.Solver; + +namespace Squadronista; internal sealed class SquadronMission { @@ -6,7 +8,6 @@ internal sealed class SquadronMission public required string Name { get; init; } public required byte Level { get; init; } public required bool IsFlaggedMission { get; init; } - public int PhysicalAbility { get; init; } - public int MentalAbility { get; init; } - public int TacticalAbility { get; init; } + + public Attributes? CurrentAttributes { get; set; } } diff --git a/Squadronista/Squadronista.csproj b/Squadronista/Squadronista.csproj index ffb8ed7..f7b611a 100644 --- a/Squadronista/Squadronista.csproj +++ b/Squadronista/Squadronista.csproj @@ -1,7 +1,7 @@ net7.0-windows - 0.2 + 0.3 11.0 enable true @@ -23,6 +23,10 @@ $(DALAMUD_HOME)/ + + + + diff --git a/Squadronista/SquadronistaPlugin.cs b/Squadronista/SquadronistaPlugin.cs index f5ef1f6..850e222 100644 --- a/Squadronista/SquadronistaPlugin.cs +++ b/Squadronista/SquadronistaPlugin.cs @@ -31,7 +31,7 @@ public class SquadronistaPlugin : IDalamudPlugin private readonly MainWindow _mainWindow; public SquadronistaPlugin(DalamudPluginInterface pluginInterface, IClientState clientState, IPluginLog pluginLog, - IDataManager dataManager, IAddonLifecycle addonLifecycle) + IDataManager dataManager, IAddonLifecycle addonLifecycle, IGameGui gameGui, IFramework framework) { _pluginInterface = pluginInterface; _clientState = clientState; @@ -39,7 +39,7 @@ public class SquadronistaPlugin : IDalamudPlugin _dataManager = dataManager; _addonLifecycle = addonLifecycle; - _classNamesToId = dataManager.GetExcelSheet()! + _classNamesToId = dataManager.GetExcelSheet()! .Where(x => x.RowId > 0) .Where(x => x.Name.ToString().Length > 0) .ToDictionary(x => x.Name.ToString().ToLower(), x => x.RowId) @@ -55,14 +55,6 @@ public class SquadronistaPlugin : IDalamudPlugin // 13 and 14 seems to be a duplicate IsFlaggedMission = x.RowId is 7 or 14 or 15 or 34, - - // not sure why this is structured the way it is - // 'Supply Wagon Escort', for example, has the following physical values: - // 210, 125, 305, 305, 210, 115 - // and the UI shows 115 as required - PhysicalAbility = x.RequiredPhysical.Last(), - MentalAbility = x.RequiredMental.Last(), - TacticalAbility = x.RequiredTactical.Last(), }) .ToList() .AsReadOnly(); @@ -86,7 +78,7 @@ public class SquadronistaPlugin : IDalamudPlugin _addonLifecycle.RegisterListener(AddonEvent.PostRefresh, "GcArmyExpedition", UpdateExpeditionState); _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "GcArmyExpedition", UpdateExpeditionState); - _mainWindow = new MainWindow(this, pluginLog, addonLifecycle); + _mainWindow = new MainWindow(this, pluginInterface, pluginLog, addonLifecycle, gameGui); _windowSystem.AddWindow(_mainWindow); } @@ -96,6 +88,8 @@ public class SquadronistaPlugin : IDalamudPlugin private unsafe void UpdateSquadronState(AddonEvent type, AddonArgs args) { + _pluginLog.Information("Updating squadron state from member list"); + AtkUnitBase* addon = (AtkUnitBase*)args.Addon; if (addon->AtkValuesCount != 133) { @@ -126,32 +120,44 @@ public class SquadronistaPlugin : IDalamudPlugin .ToList() .AsReadOnly(); byte rank = byte.Parse(ReadAtkString(atkValues[0])!.Split(":")[1].Trim()); + int[] attributes = ReadAtkString(atkValues[1])!.Split(":")[1].Trim() .Split("/") .Select(int.Parse) .ToArray(); + var bonus = new BonusAttributes + { + PhysicalAbility = attributes[0], + MentalAbility = attributes[1], + TacticalAbility = attributes[2], + Cap = attributes.Sum() + }; + + if (SquadronState != null && + members.SequenceEqual(SquadronState.Members) && + rank == SquadronState.Rank && + bonus == SquadronState.Bonus) + { + // nothing changed... + return; + } SquadronState = new SquadronState { Members = members, Rank = rank, - Bonus = new BonusAttributes - { - PhysicalAbility = attributes[0], - MentalAbility = attributes[1], - TacticalAbility = attributes[2], - Cap = attributes.Sum() - }, + Bonus = bonus, }; foreach (var member in members) - _pluginLog.Information($"MM → {member.Name}, {member.ClassJob}, Lv{member.Level}"); + _pluginLog.Verbose($"Squadron Member {member.Name}: ClassJob {member.ClassJob}, Lv{member.Level}"); } private unsafe void UpdateExpeditionState(AddonEvent type, AddonArgs args) { AvailableMissions.Clear(); - SquadronState = null; + if (type == AddonEvent.PostSetup) + SquadronState = null; AddonGcArmyExpedition* addonExpedition = (AddonGcArmyExpedition*)args.Addon; if (addonExpedition->AtkUnitBase.AtkValuesCount != 216) @@ -163,9 +169,8 @@ public class SquadronistaPlugin : IDalamudPlugin var atkValues = addonExpedition->AtkUnitBase.AtkValues; int missionCount = atkValues[6].Int; - _pluginLog.Information($"Missions → {missionCount}"); + _pluginLog.Verbose($"Missions: {missionCount}"); AvailableMissions = Enumerable.Range(0, missionCount) - .Where(i => atkValues[8 + 4 * i].Int > 0) .Select(i => _allMissions.Single(x => x.Id == atkValues[9 + 4 * i].Int)) .ToList(); } diff --git a/Squadronista/Windows/MainWindow.cs b/Squadronista/Windows/MainWindow.cs index 2a3d084..6b800c5 100644 --- a/Squadronista/Windows/MainWindow.cs +++ b/Squadronista/Windows/MainWindow.cs @@ -1,46 +1,74 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Numerics; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Game.Text; using Dalamud.Interface.Colors; using Dalamud.Interface.Windowing; +using Dalamud.Plugin; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Component.GUI; using ImGuiNET; +using LLib; +using LLib.GameUI; using Squadronista.Solver; using Task = System.Threading.Tasks.Task; +using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; namespace Squadronista.Windows; internal sealed class MainWindow : Window, IDisposable { private readonly SquadronistaPlugin _plugin; + private readonly DalamudPluginInterface _pluginInterface; private readonly IPluginLog _pluginLog; private readonly IAddonLifecycle _addonLifecycle; + private readonly IGameGui _gameGui; - public MainWindow(SquadronistaPlugin plugin, IPluginLog pluginLog, IAddonLifecycle addonLifecycle) + public MainWindow(SquadronistaPlugin plugin, DalamudPluginInterface pluginInterface, IPluginLog pluginLog, + IAddonLifecycle addonLifecycle, IGameGui gameGui) : base("Squadronista##SquadronistaMainWindow") { _plugin = plugin; + _pluginInterface = pluginInterface; _pluginLog = pluginLog; _addonLifecycle = addonLifecycle; + _gameGui = gameGui; Position = new Vector2(100, 100); PositionCondition = ImGuiCond.Always; - Flags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoCollapse - ; + Flags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoCollapse; + + SizeConstraints = new() + { + MinimumSize = new Vector2(150, 50), + MaximumSize = new Vector2(9999, 9999), + }; _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "GcArmyExpedition", ExpeditionPostSetup); _addonLifecycle.RegisterListener(AddonEvent.PreFinalize, "GcArmyExpedition", ExpeditionPreFinalize); _addonLifecycle.RegisterListener(AddonEvent.PostUpdate, "GcArmyExpedition", ExpeditionPostUpdate); - } - private void ExpeditionPostSetup(AddonEvent type, AddonArgs args) + private unsafe void ExpeditionPostSetup(AddonEvent type, AddonArgs args) { IsOpen = true; + + _pluginLog.Information("Opening GC member list..."); + var openMemmberList = stackalloc AtkValue[] + { + new() { Type = ValueType.Int, Int = 13 }, + new() { Type = 0, Int = 0 }, + new() { Type = 0, Int = 0 }, + new() { Type = 0, Int = 0 }, + new() { Type = 0, Int = 0 }, + new() { Type = 0, Int = 0 } + }; + ((AddonGcArmyExpedition*)args.Addon)->AtkUnitBase.FireCallback(6, openMemmberList); } private void ExpeditionPreFinalize(AddonEvent type, AddonArgs args) @@ -62,17 +90,20 @@ internal sealed class MainWindow : Window, IDisposable Position = new Vector2(x, y); } - public override void Draw() + public override unsafe void Draw() { - var flaggedMission = _plugin.AvailableMissions.FirstOrDefault(x => x.IsFlaggedMission); - if (flaggedMission == null) + LImGui.AddPatreonIcon(_pluginInterface); + + var agentExpedition = AgentGcArmyExpedition.Instance(); + if (agentExpedition == null || agentExpedition->SelectedRow >= _plugin.AvailableMissions.Count) { - ImGui.Text("No flagged mission available."); + ImGui.Text($"Could not find mission... ({(agentExpedition != null ? agentExpedition->SelectedRow.ToString() : "null")}; {_plugin.AvailableMissions.Count})"); return; } - ImGui.Text($"{flaggedMission.Name}"); - ImGui.Indent(); + var selectedMission = _plugin.AvailableMissions[agentExpedition->SelectedRow]; + ImGui.Text($"{selectedMission.Name}"); + var state = _plugin.SquadronState; if (state == null) { @@ -80,43 +111,89 @@ internal sealed class MainWindow : Window, IDisposable } else { - var task = state.CalculationTask; + var task = state.GetCalculation(selectedMission); if (task != null) { if (task.IsCompletedSuccessfully) { - SquadronSolver.CalculationResult? result = task.Result; - if (result != null) + SquadronSolver.CalculationResults results = task.Result; + if (results.Results.Count > 0) { - if (result.Mission?.Id != flaggedMission.Id) + // if the member list window is open, we can trivially check which member is part of the party + List? activeMembers = null; + if (_gameGui.TryGetAddonByName("GcArmyMemberList", out AtkUnitBase* addonMemberList) && + LAddon.IsAddonReady(addonMemberList) && addonMemberList->AtkValuesCount == 133) { - state.CalculationTask = null; - return; + var atkValues = addonMemberList->AtkValues; + activeMembers = Enumerable.Range(0, (int)atkValues[4].UInt) + .Where(i => atkValues[5 + i * 15].UInt == 3) + .Select(i => atkValues[6 + i * 15].ReadAtkString()) + .Where(x => !string.IsNullOrEmpty(x)) + .Cast() + .ToList(); } - ImGui.Text("Squadron Members"); - ImGui.Indent(); - foreach (var member in result.Members) - ImGui.Text($"{member.Name}"); - ImGui.Unindent(); - if (result.Trainings.Count > 0) + foreach (var result in results.Results) { - ImGui.Spacing(); - ImGui.Text($"Trainings needed ({result.Trainings.Count})"); + ImGui.TextColored( + result.MatchingAttributes == 3 ? ImGuiColors.HealerGreen : ImGuiColors.DalamudYellow, + $"{result.ToLabel()}"); ImGui.Indent(); - foreach (var training in result.Trainings) - ImGui.Text($"{training.Name}"); + + ImGui.Text($"Squadron Members ({SeIconChar.LevelEn.ToIconString()}{result.TotalLevel / 4:N0})"); + ImGui.Indent(); + foreach (var member in result.Members) + { + if (activeMembers != null) + ImGui.TextColored( + activeMembers.Contains(member.Name) + ? ImGuiColors.HealerGreen + : ImGuiColors.DalamudYellow, $"{member.Name}"); + else + ImGui.Text($"{member.Name}"); + } + + ImGui.Unindent(); + + if (result.Trainings.Count > 0) + { + ImGui.Spacing(); + ImGui.Text($"Trainings needed ({result.Trainings.Count})"); + ImGui.Indent(); + foreach (var training in result.Trainings) + ImGui.Text($"{training.Name}"); + ImGui.Unindent(); + } + + ImGui.Spacing(); + ImGui.Text("Final Stats:"); + ImGui.SameLine(0); + ImGui.TextColored( + result.PhysicalAbility >= selectedMission.CurrentAttributes.PhysicalAbility + ? ImGuiColors.HealerGreen + : ImGuiColors.DalamudYellow, $"{result.PhysicalAbility}"); + ImGui.SameLine(0); + ImGui.Text("/"); + ImGui.SameLine(0); + ImGui.TextColored( + result.MentalAbility >= selectedMission.CurrentAttributes.MentalAbility + ? ImGuiColors.HealerGreen + : ImGuiColors.DalamudYellow, $"{result.MentalAbility}"); + ImGui.SameLine(0); + ImGui.Text("/"); + ImGui.SameLine(0); + ImGui.TextColored( + result.TacticalAbility >= selectedMission.CurrentAttributes.TacticalAbility + ? ImGuiColors.HealerGreen + : ImGuiColors.DalamudYellow, $"{result.TacticalAbility}"); + ImGui.Unindent(); } - - ImGui.Spacing(); - ImGui.TextColored(result.Trainings.Count == 0 ? ImGuiColors.HealerGreen : ImGuiColors.DalamudYellow, $"Final Stats: {result.PhysicalAbility} / {result.MentalAbility} / {result.TacticalAbility}"); } else { - ImGui.TextColored(ImGuiColors.DalamudRed, - $"No combination of members/trainings can achieve {flaggedMission.PhysicalAbility} / {flaggedMission.MentalAbility} / {flaggedMission.TacticalAbility}."); + ImGui.TextColored(ImGuiColors.DalamudRed, $"No combination of members/trainings can achieve\n{(selectedMission.IsFlaggedMission ? "all" : "2 out of 3")} attributes for {selectedMission.CurrentAttributes.PhysicalAbility} / {selectedMission.CurrentAttributes.MentalAbility} / {selectedMission.CurrentAttributes.TacticalAbility}."); ImGui.Text("Level the squadron further and check again."); } } @@ -127,26 +204,142 @@ internal sealed class MainWindow : Window, IDisposable } else { - state.CalculationTask = Task.Factory.StartNew(() => + if (selectedMission.CurrentAttributes == null) + FindCurrentAttributeIndex(agentExpedition, selectedMission); + + if (selectedMission.CurrentAttributes == null) + { + ImGui.Text("No matching mission found...?"); + return; + } + + state.SetCalculation(selectedMission, Task.Factory.StartNew(() => { var solver = new SquadronSolver(_pluginLog, state, _plugin.Trainings); - var allSolutions = solver.SolveFor(flaggedMission).ToList(); - if (allSolutions.Count == 0) - return null; - int shortestTrainings = allSolutions.Min(x => x.Trainings.Count); - return allSolutions - .Where(x => x.Trainings.Count == shortestTrainings) - .OrderBy(x => x.Members.Sum(y => y.Level)) - .First(); - }); + SquadronSolver.CalculationResults results = new SquadronSolver.CalculationResults + { + IsFlaggedMission = selectedMission.IsFlaggedMission + }; + + if (selectedMission.IsFlaggedMission) + { + // only relevant when all 3 stats match + var perfectMatches = solver.SolveFor(selectedMission, 3).ToList(); + if (perfectMatches.Count > 0) + { + results.Results.Add(perfectMatches + .OrderBy(x => x.Trainings.Count) + .ThenBy(x => x.Members.Sum(y => y.Level)) + .First()); + } + } + else + { + var matches = solver.SolveFor(selectedMission, 2) + .OrderByDescending(x => x.MatchingAttributes) + .ThenBy(x => x.Trainings.Count) + .ThenBy(x => x.Members.Sum(y => y.Level)) + .ToList(); + + // optimal solution without training + var perfectMatches = + matches.Where(x => x.MatchingAttributes == 3 && x.Trainings.Count == 0).ToList(); + if (perfectMatches.Count > 0) + { + results.Results.Add(perfectMatches.First()); + matches = matches.Except(perfectMatches) + // we only want lower-level member combinations for any option with training or at 66% + .Where(x => x.TotalLevel < perfectMatches.First().TotalLevel) + .ToList(); + } + + // optimal solution, with training + var perfectMatchesWithTraining = matches.Where(x => x.MatchingAttributes == 3).ToList(); + if (perfectMatchesWithTraining.Count > 0) + { + results.Results.Add(perfectMatchesWithTraining.First()); + matches = matches.Except(perfectMatchesWithTraining).ToList(); + } + + // suboptimal solutions + if (matches.Count > 0) + { + var suboptimalMatch = matches.First(); + if (suboptimalMatch.Trainings.Count == 0 && perfectMatchesWithTraining.Any()) + results.Results.Insert(results.Results.Count - 1, suboptimalMatch); + else + results.Results.Add(suboptimalMatch); + } + } + + return results; + })); } } - ImGui.Unindent(); + } + + private unsafe void FindCurrentAttributeIndex(AgentGcArmyExpedition* agentExpedition, + SquadronMission selectedMission) + { + AddonGcArmyExpedition* addonExpedition = + (AddonGcArmyExpedition*)LAddon.GetAddonById(agentExpedition->AgentInterface.AddonId); + + // should never happen + if (addonExpedition == null || !LAddon.IsAddonReady(&addonExpedition->AtkUnitBase)) + return; + + AtkComponentBase* requiredAttribComponent = addonExpedition->RequiredAttributesComponentNode; + if (requiredAttribComponent == null) + return; + + AtkComponentNode* physicalComponent = GetNodeById(requiredAttribComponent->UldManager, 2); + AtkComponentNode* mentalComponent = GetNodeById(requiredAttribComponent->UldManager, 4); + AtkComponentNode* tacticalComponent = GetNodeById(requiredAttribComponent->UldManager, 6); + if (physicalComponent == null || mentalComponent == null || tacticalComponent == null) + { + _pluginLog.Warning("Could not parse required attribute children"); + return; + } + + AtkTextNode* physicalText = GetNodeById(physicalComponent->Component->UldManager, 2); + AtkTextNode* mentalText = GetNodeById(mentalComponent->Component->UldManager, 2); + AtkTextNode* tacticalText = GetNodeById(tacticalComponent->Component->UldManager, 2); + if (physicalText == null || mentalText == null || tacticalText == null) + { + _pluginLog.Warning("Could not parse required attribute texts"); + return; + } + + int physical = int.Parse(physicalText->NodeText.ToString()); + int mental = int.Parse(mentalText->NodeText.ToString()); + int tactical = int.Parse(tacticalText->NodeText.ToString()); + + selectedMission.CurrentAttributes = new Attributes + { + PhysicalAbility = physical, + MentalAbility = mental, + TacticalAbility = tactical, + }; + } + + private static unsafe T* GetNodeById(AtkUldManager uldManager, uint nodeId, NodeType? type = null) + where T : unmanaged + { + for (var i = 0; i < uldManager.NodeListCount; i++) + { + var n = uldManager.NodeList[i]; + if (n->NodeID != nodeId || type != null && n->Type != type.Value) continue; + if (!n->IsVisible) continue; + return (T*)n; + } + + return null; } public void Dispose() { + _addonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "GcArmyExpedition", ExpeditionPostUpdate); _addonLifecycle.UnregisterListener(AddonEvent.PreFinalize, "GcArmyExpedition", ExpeditionPreFinalize); _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "GcArmyExpedition", ExpeditionPostSetup); } diff --git a/Squadronista/packages.lock.json b/Squadronista/packages.lock.json index 6cf1c73..784f99f 100644 --- a/Squadronista/packages.lock.json +++ b/Squadronista/packages.lock.json @@ -7,6 +7,9 @@ "requested": "[2.1.12, )", "resolved": "2.1.12", "contentHash": "Sc0PVxvgg4NQjcI8n10/VfUQBAS4O+Fw2pZrAqBdRMbthYGeogzu5+xmIGCGmsEZ/ukMOBuAqiNiB5qA3MRalg==" + }, + "llib": { + "type": "Project" } } }