From 8e3d039ba48db8a8e179a9e5d7871fdec8690720 Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Mon, 9 Oct 2023 19:29:35 +0200 Subject: [PATCH] Initial testing --- .gitignore | 2 + Squadronista.sln | 16 ++ Squadronista/.gitignore | 3 + Squadronista/DalamudPackager.targets | 21 +++ Squadronista/Solver/BonusAttributes.cs | 107 +++++++++++++ Squadronista/Solver/Race.cs | 13 ++ Squadronista/Solver/SquadronMember.cs | 40 +++++ Squadronista/Solver/SquadronSolver.cs | 208 +++++++++++++++++++++++++ Squadronista/Solver/SquadronState.cs | 13 ++ Squadronista/Solver/Training.cs | 25 +++ Squadronista/SquadronMission.cs | 12 ++ Squadronista/Squadronista.csproj | 60 +++++++ Squadronista/Squadronista.json | 9 ++ Squadronista/SquadronistaPlugin.cs | 189 ++++++++++++++++++++++ Squadronista/Windows/MainWindow.cs | 153 ++++++++++++++++++ Squadronista/packages.lock.json | 13 ++ global.json | 7 + 17 files changed, 891 insertions(+) create mode 100644 .gitignore create mode 100644 Squadronista.sln create mode 100644 Squadronista/.gitignore create mode 100644 Squadronista/DalamudPackager.targets create mode 100644 Squadronista/Solver/BonusAttributes.cs create mode 100644 Squadronista/Solver/Race.cs create mode 100644 Squadronista/Solver/SquadronMember.cs create mode 100644 Squadronista/Solver/SquadronSolver.cs create mode 100644 Squadronista/Solver/SquadronState.cs create mode 100644 Squadronista/Solver/Training.cs create mode 100644 Squadronista/SquadronMission.cs create mode 100644 Squadronista/Squadronista.csproj create mode 100644 Squadronista/Squadronista.json create mode 100644 Squadronista/SquadronistaPlugin.cs create mode 100644 Squadronista/Windows/MainWindow.cs create mode 100644 Squadronista/packages.lock.json create mode 100644 global.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05dc549 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.idea +*.user diff --git a/Squadronista.sln b/Squadronista.sln new file mode 100644 index 0000000..c707ee5 --- /dev/null +++ b/Squadronista.sln @@ -0,0 +1,16 @@ + +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 +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {71E7BF47-88EE-48F4-8FBE-F89F20F34F38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 + EndGlobalSection +EndGlobal diff --git a/Squadronista/.gitignore b/Squadronista/.gitignore new file mode 100644 index 0000000..958518b --- /dev/null +++ b/Squadronista/.gitignore @@ -0,0 +1,3 @@ +/dist +/obj +/bin diff --git a/Squadronista/DalamudPackager.targets b/Squadronista/DalamudPackager.targets new file mode 100644 index 0000000..51bd213 --- /dev/null +++ b/Squadronista/DalamudPackager.targets @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/Squadronista/Solver/BonusAttributes.cs b/Squadronista/Solver/BonusAttributes.cs new file mode 100644 index 0000000..ac1c279 --- /dev/null +++ b/Squadronista/Solver/BonusAttributes.cs @@ -0,0 +1,107 @@ +using System; + +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 +{ + 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) + { + if (PhysicalAbility + MentalAbility + TacticalAbility == Cap) + { + int physicalGained = training.CappedPhysicalGained; + int mentalGained = training.CappedMentalGained; + int tacticalGained = training.CappedTacticalGained; + + int newPhysicalAbility = PhysicalAbility + physicalGained; + int newMentalAbility = MentalAbility + mentalGained; + int newTacticalAbility = TacticalAbility + tacticalGained; + + Fix(ref newPhysicalAbility, physicalGained, + ref newMentalAbility, mentalGained, + ref newTacticalAbility, tacticalGained); + + Fix(ref newMentalAbility, mentalGained, + ref newPhysicalAbility, physicalGained, + ref newTacticalAbility, tacticalGained); + + Fix(ref newTacticalAbility, tacticalGained, + ref newPhysicalAbility, physicalGained, + ref newMentalAbility, mentalGained); + + return new BonusAttributes + { + PhysicalAbility = newPhysicalAbility, + MentalAbility = newMentalAbility, + TacticalAbility = newTacticalAbility, + Cap = Cap + }; + } + else + { + return new BonusAttributes + { + PhysicalAbility = PhysicalAbility + training.PhysicalGained, + MentalAbility = MentalAbility + training.MentalGained, + TacticalAbility = TacticalAbility + training.TacticalGained, + Cap = Cap + training.PhysicalGained + training.MentalGained + training.TacticalGained, + }; + } + } + + private void Fix(ref int mainStat, int mainGained, ref int otherStatA, int otherGainedA, ref int otherStatB, int otherGainedB) + { + if (mainStat >= 0 || mainGained > 0) + return; + + // if both stats should gain, this training is just invalid + if (otherGainedA > 0 && otherGainedB > 0) + return; + + if (otherGainedA > 0) + otherStatB += mainStat; + + if (otherGainedB > 0) + otherStatA += mainStat; + + mainStat = 0; + } + + public bool Equals(BonusAttributes? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return PhysicalAbility == other.PhysicalAbility && MentalAbility == other.MentalAbility && TacticalAbility == other.TacticalAbility && Cap == other.Cap; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((BonusAttributes)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine(PhysicalAbility, MentalAbility, TacticalAbility, Cap); + } + + public static bool operator ==(BonusAttributes? left, BonusAttributes? right) + { + return Equals(left, right); + } + + public static bool operator !=(BonusAttributes? left, BonusAttributes? right) + { + return !Equals(left, right); + } +} diff --git a/Squadronista/Solver/Race.cs b/Squadronista/Solver/Race.cs new file mode 100644 index 0000000..a08be18 --- /dev/null +++ b/Squadronista/Solver/Race.cs @@ -0,0 +1,13 @@ +namespace Squadronista.Solver; + +internal enum Race +{ + Hyur = 1, + Elezen, + Lalafell, + Miqote, + Roegadyn, + AuRa, + // Hrotgar, + // Viera +} diff --git a/Squadronista/Solver/SquadronMember.cs b/Squadronista/Solver/SquadronMember.cs new file mode 100644 index 0000000..3969d01 --- /dev/null +++ b/Squadronista/Solver/SquadronMember.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using Dalamud.Plugin.Services; +using Lumina.Excel.GeneratedSheets; + +namespace Squadronista.Solver; + +internal sealed class SquadronMember +{ + public required string Name { get; init; } + public required int Level { get; init; } + public required uint ClassJob { get; init; } + // TODO + public required Race Race { get; init; } + + // TODO + public int Experience { get; init; } + public int PhysicalAbility => GrowthParams[Level].PhysicalAbility; + public int MentalAbility => GrowthParams[Level].MentalAbility; + public int TacticalAbility => GrowthParams[Level].TacticalAbility; + + public IReadOnlyList<(byte PhysicalAbility, byte MentalAbility, byte TacticalAbility)> GrowthParams + { + get; + private set; + } = new List<(byte PhysicalAbility, byte MentalAbility, byte TacticalAbility)>().AsReadOnly(); + + public SquadronMember InitializeFrom(IDataManager dataManager) + { + List<(byte, byte, byte)> growthAsList = new(); + var growth = dataManager.GetExcelSheet()!.Single(x => x.ClassJob.Row == ClassJob); + for (int i = 0; i <= 59; ++i) + { + growthAsList.Add((growth.Physical[i], growth.Mental[i], growth.Tactical[i])); + } + growthAsList.Add((growth.Unknown123, growth.Unknown184, growth.Unknown245)); + GrowthParams = growthAsList; + return this; + } +} diff --git a/Squadronista/Solver/SquadronSolver.cs b/Squadronista/Solver/SquadronSolver.cs new file mode 100644 index 0000000..6cc8aad --- /dev/null +++ b/Squadronista/Solver/SquadronSolver.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using System.Linq; +using Dalamud.Plugin.Services; + +namespace Squadronista.Solver; + +internal sealed class SquadronSolver +{ + private readonly IPluginLog _pluginLog; + private readonly SquadronState _state; + private readonly IReadOnlyList _trainings; + private readonly IReadOnlyList> _memberCombinations; + private readonly IReadOnlyList _allBonusCombinations; + + private readonly Dictionary> _calculatedTrainings = new(); + private List _newTrainings; + private int _calculatedTrainingSteps; + + public SquadronSolver(IPluginLog pluginLog, SquadronState state, IReadOnlyList trainings) + { + _pluginLog = pluginLog; + _state = state; + _trainings = trainings; + _memberCombinations = MakeCombinations(_state.Members, 4) + .DistinctBy(x => string.Join("|", x.Select(y => y.Name).Order())) + .ToList() + .AsReadOnly(); + + foreach (var combo in _memberCombinations) + pluginLog.Verbose($"Squadron member combination: {string.Join(" ", combo.Select(x => x.Name))} → {combo.Sum(x => x.PhysicalAbility)} / {combo.Sum(x => x.MentalAbility)} / {combo.Sum(x => x.TacticalAbility)}"); + + _allBonusCombinations = CalculateAllBonuses() + .Where(x => x != _state.Bonus) + .ToList() + .AsReadOnly(); + + _calculatedTrainings[_state.Bonus] = new List(); + _newTrainings = _calculatedTrainings.Keys.ToList(); + } + + public IEnumerable SolveFor(SquadronMission mission) + { + int minPhysical = mission.PhysicalAbility; + int minMental = mission.MentalAbility; + int minTactical = mission.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) + { + x.TrainingsCalculated = true; + yield return x.WithMission(mission); + + foundWithoutTraining = true; + } + } + + if (!foundWithoutTraining) + { + foreach (var bonus in _allBonusCombinations) + { + intermediates = CalculateForAllMemberCombinations(mission.Level, bonus); + foreach (var x in intermediates) + { + if (x.PhysicalAbility >= minPhysical && x.MentalAbility >= minMental && + x.TacticalAbility >= minTactical) + { + CalculateTrainingsForBonus(x); + + if (x.TrainingsCalculated) + yield return x.WithMission(mission); + } + } + } + } + } + + private List CalculateForAllMemberCombinations(int requiredLevel, BonusAttributes bonus) + { + return _memberCombinations + .Where(x => x.Any(y => y.Level >= requiredLevel)) + .Select(x => new CalculationResult(x, bonus)) + .ToList(); + } + + private List> MakeCombinations(IReadOnlyList entries, int count) + { + if (count == 0) + return new List>(); + + if (count == 1) + return entries.Select(x => new List { x }).ToList(); + + return entries.SelectMany(x => + { + var combos = MakeCombinations(entries.Except(new[] { x }).ToList(), count - 1); + combos.ForEach(c => c.Insert(0, x)); + return combos; + }).ToList(); + } + + private IEnumerable CalculateAllBonuses() + { + for (int physical = 0; physical <= _state.Bonus.Cap; physical += 20) + { + for (int mental = 0; mental <= _state.Bonus.Cap - physical; mental += 20) + { + yield return new BonusAttributes + { + PhysicalAbility = physical, + MentalAbility = mental, + TacticalAbility = _state.Bonus.Cap - physical - mental, + Cap = _state.Bonus.Cap + }; + } + } + } + + private void CalculateTrainingsForBonus(CalculationResult result) + { + if (result.Trainings.Count > 0) + return; + + int currentPhysical = _state.Bonus.PhysicalAbility; + int currentMental = _state.Bonus.MentalAbility; + int currentTactical = _state.Bonus.TacticalAbility; + + + if (_calculatedTrainings.TryGetValue(result.Bonus, out var calculatedTraining)) + { + _pluginLog.Information($"Found existing steps: {string.Join(", ", calculatedTraining.Select(x => x.Name))}"); + result.Trainings = calculatedTraining; + result.TrainingsCalculated = true; + return; + } + + _pluginLog.Verbose($"Trying to find steps from {currentPhysical} to {result.Bonus.PhysicalAbility}, {currentMental} to {result.Bonus.MentalAbility} and {currentTactical} to {result.Bonus.TacticalAbility}"); + + while (_calculatedTrainingSteps < 10) + { + _calculatedTrainingSteps++; + _pluginLog.Debug($"Calculating training step {_calculatedTrainingSteps} from currently {_calculatedTrainings.Count} training combinations"); + + List newerTrainings = new(); + foreach (var bonus in _newTrainings) + { + _pluginLog.Verbose($" From {bonus.PhysicalAbility} / {bonus.MentalAbility} / {bonus.TacticalAbility}"); + foreach (var training in _trainings) + { + var newBonus = bonus.ApplyTraining(training); + if (newBonus.PhysicalAbility < 0 || newBonus.MentalAbility < 0 || newBonus.TacticalAbility < 0) + continue; + + if (_calculatedTrainings.ContainsKey(newBonus)) + continue; + + _pluginLog.Verbose($" → {newBonus.PhysicalAbility} / {newBonus.MentalAbility} / {newBonus.TacticalAbility} with {training.Name} (c = {newBonus.Cap})"); + _calculatedTrainings[newBonus] = _calculatedTrainings[bonus].Concat(new[] { training }).ToList().AsReadOnly(); + newerTrainings.Add(newBonus); + } + } + + _newTrainings = newerTrainings; + + _pluginLog.Verbose($"Finished calculating, we now have {_calculatedTrainings.Count} training combinations"); + if (newerTrainings.Count == 0) + break; + + if (_calculatedTrainings.TryGetValue(result.Bonus, out calculatedTraining)) + { + _pluginLog.Verbose($"Found steps to reach {result.PhysicalAbility} / {result.MentalAbility} / {result.TacticalAbility}: {string.Join(", ", calculatedTraining.Select(x => x.Name))}"); + result.Trainings = calculatedTraining; + result.TrainingsCalculated = true; + return; + } + } + } + + public sealed class CalculationResult + { + public SquadronMission? Mission { get; private set; } + public int PhysicalAbility { get; } + public int MentalAbility { get; } + public int TacticalAbility { get; } + public List Members { get; } + public BonusAttributes Bonus { get; } + public IReadOnlyList Trainings { get; set; } = new List().AsReadOnly(); + public bool TrainingsCalculated { get; set; } + + public CalculationResult(List members, BonusAttributes bonus) + { + PhysicalAbility = members.Sum(x => x.PhysicalAbility) + bonus.PhysicalAbility; + MentalAbility = members.Sum(x => x.MentalAbility) + bonus.MentalAbility; + TacticalAbility = members.Sum(x => x.TacticalAbility) + bonus.TacticalAbility; + Members = members; + Bonus = bonus; + } + + public CalculationResult WithMission(SquadronMission mission) + { + Mission = mission; + return this; + } + } +} diff --git a/Squadronista/Solver/SquadronState.cs b/Squadronista/Solver/SquadronState.cs new file mode 100644 index 0000000..4eb50b4 --- /dev/null +++ b/Squadronista/Solver/SquadronState.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squadronista.Solver; + +internal sealed class SquadronState +{ + public required byte Rank { get; init; } + public required IReadOnlyList Members { get; init; } + public required BonusAttributes Bonus { get; set; } + + public Task? CalculationTask { get; set; } +} diff --git a/Squadronista/Solver/Training.cs b/Squadronista/Solver/Training.cs new file mode 100644 index 0000000..1bfbe7a --- /dev/null +++ b/Squadronista/Solver/Training.cs @@ -0,0 +1,25 @@ +namespace Squadronista.Solver; + +public class Training +{ + public required uint RowId { get; init; } + public required string Name { get; init; } + public required int PhysicalGained { get; init; } + public required int MentalGained { get; init; } + public required int TacticalGained { get; init; } + + public int CappedPhysicalGained => CalculateCapped(PhysicalGained, MentalGained, TacticalGained); + public int CappedMentalGained => CalculateCapped(MentalGained, PhysicalGained, TacticalGained); + public int CappedTacticalGained => CalculateCapped(TacticalGained, PhysicalGained, MentalGained); + + private int CalculateCapped(int mainStat, int otherA, int otherB) + { + if (mainStat > 0) + return mainStat; + + if (otherA == 40 || otherB == 40) + return -20; + + return -40; + } +} diff --git a/Squadronista/SquadronMission.cs b/Squadronista/SquadronMission.cs new file mode 100644 index 0000000..954470a --- /dev/null +++ b/Squadronista/SquadronMission.cs @@ -0,0 +1,12 @@ +namespace Squadronista; + +internal sealed class SquadronMission +{ + public required int Id { get; init; } + 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; } +} diff --git a/Squadronista/Squadronista.csproj b/Squadronista/Squadronista.csproj new file mode 100644 index 0000000..d0ca416 --- /dev/null +++ b/Squadronista/Squadronista.csproj @@ -0,0 +1,60 @@ + + + net7.0-windows + 0.1 + 11.0 + enable + true + false + false + dist + true + portable + $(SolutionDir)=X:\ + true + portable + + + + $(appdata)\XIVLauncher\addon\Hooks\dev\ + + + + $(DALAMUD_HOME)/ + + + + + + + + + $(DalamudLibPath)Dalamud.dll + false + + + $(DalamudLibPath)ImGui.NET.dll + false + + + $(DalamudLibPath)Lumina.dll + false + + + $(DalamudLibPath)Lumina.Excel.dll + false + + + $(DalamudLibPath)Newtonsoft.Json.dll + false + + + $(DalamudLibPath)FFXIVClientStructs.dll + false + + + + + + + diff --git a/Squadronista/Squadronista.json b/Squadronista/Squadronista.json new file mode 100644 index 0000000..a94faf3 --- /dev/null +++ b/Squadronista/Squadronista.json @@ -0,0 +1,9 @@ +{ + "Name": "Squadronista", + "Author": "Liza Carvelli", + "Punchline": "Simplified Squadron Calculator for Flagged Missions, heavily inspired by https://ffxivsquadron.com/", + "Description": "", + "RepoUrl": "https://git.carvel.li/liza/Squadronista", + "IconUrl": "https://git.carvel.li/liza/plugin-repo/raw/branch/master/dist/Squadronista.png", + "IsTestingExclusive": true +} diff --git a/Squadronista/SquadronistaPlugin.cs b/Squadronista/SquadronistaPlugin.cs new file mode 100644 index 0000000..e32939b --- /dev/null +++ b/Squadronista/SquadronistaPlugin.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Interface.Windowing; +using Dalamud.Memory; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Lumina.Excel.GeneratedSheets; +using Squadronista.Solver; +using Squadronista.Windows; +using Race = Squadronista.Solver.Race; + +namespace Squadronista; + +public class SquadronistaPlugin : IDalamudPlugin +{ + private readonly WindowSystem _windowSystem = new(nameof(SquadronistaPlugin)); + + private readonly DalamudPluginInterface _pluginInterface; + private readonly IClientState _clientState; + private readonly IPluginLog _pluginLog; + private readonly IDataManager _dataManager; + private readonly IAddonLifecycle _addonLifecycle; + private readonly IReadOnlyDictionary _classNamesToId; + private readonly IReadOnlyList _allMissions; + private readonly MainWindow _mainWindow; + + public SquadronistaPlugin(DalamudPluginInterface pluginInterface, IClientState clientState, IPluginLog pluginLog, + IDataManager dataManager, IAddonLifecycle addonLifecycle) + { + _pluginInterface = pluginInterface; + _clientState = clientState; + _pluginLog = pluginLog; + _dataManager = dataManager; + _addonLifecycle = addonLifecycle; + + _classNamesToId = dataManager.GetExcelSheet()! + .Where(x => x.RowId > 0) + .Where(x => x.Name.ToString().Length > 0) + .ToDictionary(x => x.Name.ToString().ToLower(), x => x.RowId) + .AsReadOnly(); + + _allMissions = dataManager.GetExcelSheet()! + .Where(x => x.RowId > 0) + .Select(x => new SquadronMission + { + Id = (int)x.RowId, + Name = x.Name.ToString(), + Level = x.RequiredLevel, + + // 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(); + + Trainings = dataManager.GetExcelSheet()! + .Where(x => x.RowId > 0 && x.RowId != 7) + .Select(x => new Training + { + RowId = x.RowId, + Name = x.Name.ToString(), + PhysicalGained = x.PhysicalBonus, + MentalGained = x.MentalBonus, + TacticalGained = x.TacticalBonus + }) + .ToList() + .AsReadOnly(); + + _pluginInterface.UiBuilder.Draw += _windowSystem.Draw; + _clientState.Logout += Logout; + _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "GcArmyMemberList", UpdateSquadronState); + _addonLifecycle.RegisterListener(AddonEvent.PostRefresh, "GcArmyExpedition", UpdateExpeditionState); + _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "GcArmyExpedition", UpdateExpeditionState); + + _mainWindow = new MainWindow(this, pluginLog, addonLifecycle); + _windowSystem.AddWindow(_mainWindow); + } + + internal SquadronState? SquadronState { get; set; } + internal List AvailableMissions { get; set; } = new(); + internal IReadOnlyList Trainings { get; set; } + + private unsafe void UpdateSquadronState(AddonEvent type, AddonArgs args) + { + AtkUnitBase* addon = (AtkUnitBase*)args.Addon; + if (addon->AtkValuesCount != 133) + { + _pluginLog.Error("Unexpected AddonGcArmyMemberList atkvalues count"); + return; + } + + var atkValues = addon->AtkValues; + uint memberCount = atkValues[4].UInt; + + // can't do any missions like this... + if (memberCount < 4) + return; + + IReadOnlyList members = Enumerable.Range(0, (int)memberCount) + .Select(i => new SquadronMember + { + Name = ReadAtkString(atkValues[6 + 15 * i])!, + Level = atkValues[10 + 15 * i].Int, + ClassJob = _classNamesToId[ReadAtkString(atkValues[7 + 15 * i])!.ToLower()], + Race = Race.Lalafell, // TODO + Experience = 0, // TODO + }.InitializeFrom(_dataManager)) + .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(); + + SquadronState = new SquadronState + { + Members = members, + Rank = rank, + Bonus = new BonusAttributes + { + PhysicalAbility = attributes[0], + MentalAbility = attributes[1], + TacticalAbility = attributes[2], + Cap = attributes.Sum() + }, + }; + + foreach (var member in members) + _pluginLog.Information($"MM → {member.Name}, {member.ClassJob}, Lv{member.Level}"); + } + + private unsafe void UpdateExpeditionState(AddonEvent type, AddonArgs args) + { + AvailableMissions.Clear(); + + AddonGcArmyExpedition* addonExpedition = (AddonGcArmyExpedition*)args.Addon; + if (addonExpedition->AtkUnitBase.AtkValuesCount != 216) + { + _pluginLog.Error("Unexpected AddonGcArmyExpedition atkvalues count"); + return; + } + + var atkValues = addonExpedition->AtkUnitBase.AtkValues; + int missionCount = atkValues[6].Int; + + _pluginLog.Information($"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(); + } + + private unsafe string? ReadAtkString(AtkValue atkValue) + { + if (atkValue.String != null) + return MemoryHelper.ReadSeStringNullTerminated(new nint(atkValue.String)).ToString(); + return null; + } + + private void Logout() + { + SquadronState = null; + } + + public void Dispose() + { + _mainWindow.Dispose(); + _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "GcArmyExpedition", UpdateExpeditionState); + _addonLifecycle.UnregisterListener(AddonEvent.PostRefresh, "GcArmyExpedition", UpdateExpeditionState); + _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "GcArmyMemberList", UpdateSquadronState); + _clientState.Logout -= Logout; + _pluginInterface.UiBuilder.Draw -= _windowSystem.Draw; + } +} diff --git a/Squadronista/Windows/MainWindow.cs b/Squadronista/Windows/MainWindow.cs new file mode 100644 index 0000000..2a3d084 --- /dev/null +++ b/Squadronista/Windows/MainWindow.cs @@ -0,0 +1,153 @@ +using System; +using System.Linq; +using System.Numerics; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using ImGuiNET; +using Squadronista.Solver; +using Task = System.Threading.Tasks.Task; + +namespace Squadronista.Windows; + +internal sealed class MainWindow : Window, IDisposable +{ + private readonly SquadronistaPlugin _plugin; + private readonly IPluginLog _pluginLog; + private readonly IAddonLifecycle _addonLifecycle; + + public MainWindow(SquadronistaPlugin plugin, IPluginLog pluginLog, IAddonLifecycle addonLifecycle) + : base("Squadronista##SquadronistaMainWindow") + { + _plugin = plugin; + _pluginLog = pluginLog; + _addonLifecycle = addonLifecycle; + + Position = new Vector2(100, 100); + PositionCondition = ImGuiCond.Always; + Flags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoCollapse + ; + + _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) + { + IsOpen = true; + } + + private void ExpeditionPreFinalize(AddonEvent type, AddonArgs args) + { + IsOpen = false; + } + + private unsafe void ExpeditionPostUpdate(AddonEvent type, AddonArgs args) + { + AtkUnitBase* addon = (AtkUnitBase*)args.Addon; + short x = 0, y = 0; + addon->GetPosition(&x, &y); + + short width = 0, height = 0; + addon->GetSize(&width, &height, true); + x += width; + + if ((short)Position!.Value.X != x || (short)Position!.Value.Y != y) + Position = new Vector2(x, y); + } + + public override void Draw() + { + var flaggedMission = _plugin.AvailableMissions.FirstOrDefault(x => x.IsFlaggedMission); + if (flaggedMission == null) + { + ImGui.Text("No flagged mission available."); + return; + } + + ImGui.Text($"{flaggedMission.Name}"); + ImGui.Indent(); + var state = _plugin.SquadronState; + if (state == null) + { + ImGui.TextColored(ImGuiColors.DalamudYellow, "Open Squadron Member list to continue."); + } + else + { + var task = state.CalculationTask; + if (task != null) + { + if (task.IsCompletedSuccessfully) + { + SquadronSolver.CalculationResult? result = task.Result; + if (result != null) + { + if (result.Mission?.Id != flaggedMission.Id) + { + state.CalculationTask = null; + return; + } + + ImGui.Text("Squadron Members"); + ImGui.Indent(); + foreach (var member in result.Members) + 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.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.Text("Level the squadron further and check again."); + } + } + else if (task.IsCanceled || task.IsFaulted) + ImGui.Text($"{task.Exception}"); + else + ImGui.Text("Calculating..."); + } + else + { + state.CalculationTask = 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(); + }); + } + } + ImGui.Unindent(); + } + + public void Dispose() + { + _addonLifecycle.UnregisterListener(AddonEvent.PreFinalize, "GcArmyExpedition", ExpeditionPreFinalize); + _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "GcArmyExpedition", ExpeditionPostSetup); + } +} diff --git a/Squadronista/packages.lock.json b/Squadronista/packages.lock.json new file mode 100644 index 0000000..6cf1c73 --- /dev/null +++ b/Squadronista/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net7.0-windows7.0": { + "DalamudPackager": { + "type": "Direct", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "Sc0PVxvgg4NQjcI8n10/VfUQBAS4O+Fw2pZrAqBdRMbthYGeogzu5+xmIGCGmsEZ/ukMOBuAqiNiB5qA3MRalg==" + } + } + } +} \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..aaac9e0 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "7.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file