Initial testing
This commit is contained in:
commit
8e3d039ba4
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/.idea
|
||||
*.user
|
16
Squadronista.sln
Normal file
16
Squadronista.sln
Normal file
@ -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
|
3
Squadronista/.gitignore
vendored
Normal file
3
Squadronista/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/dist
|
||||
/obj
|
||||
/bin
|
21
Squadronista/DalamudPackager.targets
Normal file
21
Squadronista/DalamudPackager.targets
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project>
|
||||
<Target Name="PackagePlugin" AfterTargets="Build" Condition="'$(Configuration)' == 'Debug'">
|
||||
<DalamudPackager
|
||||
ProjectDir="$(ProjectDir)"
|
||||
OutputPath="$(OutputPath)"
|
||||
AssemblyName="$(AssemblyName)"
|
||||
MakeZip="false"
|
||||
VersionComponents="2"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="PackagePlugin" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
|
||||
<DalamudPackager
|
||||
ProjectDir="$(ProjectDir)"
|
||||
OutputPath="$(OutputPath)"
|
||||
AssemblyName="$(AssemblyName)"
|
||||
MakeZip="true"
|
||||
VersionComponents="2"
|
||||
Exclude="Squadronista.deps.json"/>
|
||||
</Target>
|
||||
</Project>
|
107
Squadronista/Solver/BonusAttributes.cs
Normal file
107
Squadronista/Solver/BonusAttributes.cs
Normal file
@ -0,0 +1,107 @@
|
||||
using System;
|
||||
|
||||
namespace Squadronista.Solver;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
internal sealed class BonusAttributes : IEquatable<BonusAttributes>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
13
Squadronista/Solver/Race.cs
Normal file
13
Squadronista/Solver/Race.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace Squadronista.Solver;
|
||||
|
||||
internal enum Race
|
||||
{
|
||||
Hyur = 1,
|
||||
Elezen,
|
||||
Lalafell,
|
||||
Miqote,
|
||||
Roegadyn,
|
||||
AuRa,
|
||||
// Hrotgar,
|
||||
// Viera
|
||||
}
|
40
Squadronista/Solver/SquadronMember.cs
Normal file
40
Squadronista/Solver/SquadronMember.cs
Normal file
@ -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<GcArmyMemberGrow>()!.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;
|
||||
}
|
||||
}
|
208
Squadronista/Solver/SquadronSolver.cs
Normal file
208
Squadronista/Solver/SquadronSolver.cs
Normal file
@ -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<Training> _trainings;
|
||||
private readonly IReadOnlyList<List<SquadronMember>> _memberCombinations;
|
||||
private readonly IReadOnlyList<BonusAttributes> _allBonusCombinations;
|
||||
|
||||
private readonly Dictionary<BonusAttributes, IReadOnlyList<Training>> _calculatedTrainings = new();
|
||||
private List<BonusAttributes> _newTrainings;
|
||||
private int _calculatedTrainingSteps;
|
||||
|
||||
public SquadronSolver(IPluginLog pluginLog, SquadronState state, IReadOnlyList<Training> 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<Training>();
|
||||
_newTrainings = _calculatedTrainings.Keys.ToList();
|
||||
}
|
||||
|
||||
public IEnumerable<CalculationResult> SolveFor(SquadronMission mission)
|
||||
{
|
||||
int minPhysical = mission.PhysicalAbility;
|
||||
int minMental = mission.MentalAbility;
|
||||
int minTactical = mission.TacticalAbility;
|
||||
|
||||
bool foundWithoutTraining = false;
|
||||
List<CalculationResult> 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<CalculationResult> CalculateForAllMemberCombinations(int requiredLevel, BonusAttributes bonus)
|
||||
{
|
||||
return _memberCombinations
|
||||
.Where(x => x.Any(y => y.Level >= requiredLevel))
|
||||
.Select(x => new CalculationResult(x, bonus))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private List<List<T>> MakeCombinations<T>(IReadOnlyList<T> entries, int count)
|
||||
{
|
||||
if (count == 0)
|
||||
return new List<List<T>>();
|
||||
|
||||
if (count == 1)
|
||||
return entries.Select(x => new List<T> { 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<BonusAttributes> 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<BonusAttributes> 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<SquadronMember> Members { get; }
|
||||
public BonusAttributes Bonus { get; }
|
||||
public IReadOnlyList<Training> Trainings { get; set; } = new List<Training>().AsReadOnly();
|
||||
public bool TrainingsCalculated { get; set; }
|
||||
|
||||
public CalculationResult(List<SquadronMember> 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;
|
||||
}
|
||||
}
|
||||
}
|
13
Squadronista/Solver/SquadronState.cs
Normal file
13
Squadronista/Solver/SquadronState.cs
Normal file
@ -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<SquadronMember> Members { get; init; }
|
||||
public required BonusAttributes Bonus { get; set; }
|
||||
|
||||
public Task<SquadronSolver.CalculationResult?>? CalculationTask { get; set; }
|
||||
}
|
25
Squadronista/Solver/Training.cs
Normal file
25
Squadronista/Solver/Training.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
12
Squadronista/SquadronMission.cs
Normal file
12
Squadronista/SquadronMission.cs
Normal file
@ -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; }
|
||||
}
|
60
Squadronista/Squadronista.csproj
Normal file
60
Squadronista/Squadronista.csproj
Normal file
@ -0,0 +1,60 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0-windows</TargetFramework>
|
||||
<Version>0.1</Version>
|
||||
<LangVersion>11.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<OutputPath>dist</OutputPath>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<DebugType>portable</DebugType>
|
||||
<PathMap Condition="$(SolutionDir) != ''">$(SolutionDir)=X:\</PathMap>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<DalamudLibPath>$(appdata)\XIVLauncher\addon\Hooks\dev\</DalamudLibPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))'">
|
||||
<DalamudLibPath>$(DALAMUD_HOME)/</DalamudLibPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DalamudPackager" Version="2.1.12"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Dalamud">
|
||||
<HintPath>$(DalamudLibPath)Dalamud.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="ImGui.NET">
|
||||
<HintPath>$(DalamudLibPath)ImGui.NET.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Lumina">
|
||||
<HintPath>$(DalamudLibPath)Lumina.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Lumina.Excel">
|
||||
<HintPath>$(DalamudLibPath)Lumina.Excel.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Newtonsoft.Json">
|
||||
<HintPath>$(DalamudLibPath)Newtonsoft.Json.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="FFXIVClientStructs">
|
||||
<HintPath>$(DalamudLibPath)FFXIVClientStructs.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="RenameLatestZip" AfterTargets="PackagePlugin" Condition="'$(Configuration)' == 'Release'">
|
||||
<Exec Command="rename $(OutDir)$(AssemblyName)\latest.zip $(AssemblyName)-$(Version).zip"/>
|
||||
</Target>
|
||||
</Project>
|
9
Squadronista/Squadronista.json
Normal file
9
Squadronista/Squadronista.json
Normal file
@ -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
|
||||
}
|
189
Squadronista/SquadronistaPlugin.cs
Normal file
189
Squadronista/SquadronistaPlugin.cs
Normal file
@ -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<string, uint> _classNamesToId;
|
||||
private readonly IReadOnlyList<SquadronMission> _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<Lumina.Excel.GeneratedSheets.ClassJob>()!
|
||||
.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<GcArmyExpedition>()!
|
||||
.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<GcArmyTraining>()!
|
||||
.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<SquadronMission> AvailableMissions { get; set; } = new();
|
||||
internal IReadOnlyList<Training> 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<SquadronMember> 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;
|
||||
}
|
||||
}
|
153
Squadronista/Windows/MainWindow.cs
Normal file
153
Squadronista/Windows/MainWindow.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
13
Squadronista/packages.lock.json
Normal file
13
Squadronista/packages.lock.json
Normal file
@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
7
global.json
Normal file
7
global.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "7.0.0",
|
||||
"rollForward": "latestMinor",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user