using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Reflection; using System.Threading; using System.Threading.Channels; using ImGuiNET; using Lumina.Excel; using Lumina.Excel.GeneratedSheets; using Microsoft.Msagl.Core.Geometry; using Microsoft.Msagl.Core.Geometry.Curves; using Microsoft.Msagl.Core.Layout; using Microsoft.Msagl.Layout.Layered; using Microsoft.Msagl.Miscellaneous; using Action = Lumina.Excel.GeneratedSheets.Action; namespace QuestMap { internal class Quests { private Plugin Plugin { get; } private Dictionary> AllNodes { get; } internal IReadOnlyDictionary> ItemRewards { get; } internal IReadOnlyDictionary EmoteRewards { get; } internal IReadOnlyDictionary ActionRewards { get; } internal IReadOnlyDictionary> InstanceRewards { get; } internal IReadOnlyDictionary BeastRewards { get; } internal IReadOnlyDictionary JobRewards { get; } private ChannelWriter GraphChannel { get; } private LayoutAlgorithmSettings LayoutSettings { get; } = new SugiyamaLayoutSettings(); internal Quests(Plugin plugin, ChannelWriter graphChannel) { this.Plugin = plugin; this.GraphChannel = graphChannel; var itemRewards = new Dictionary>(); var emoteRewards = new Dictionary(); var actionRewards = new Dictionary(); var instanceRewards = new Dictionary>(); var beastRewards = new Dictionary(); var jobRewards = new Dictionary(); var linkedInstances = new HashSet(); var allQuests = new Dictionary(); foreach (var quest in this.Plugin.DataManager.GetExcelSheet()!) { if (quest.Name.RawString.Length == 0 || quest.RowId == 65536) { continue; } allQuests[quest.RowId] = quest; if (quest.EmoteReward.Row != 0) { emoteRewards[quest.RowId] = quest.EmoteReward.Value!; } foreach (var row in quest.ItemReward.Where(item => item != 0)) { var item = this.Plugin.DataManager.GetExcelSheet()!.GetRow(row); if (item == null) { continue; } List rewards; if (itemRewards.TryGetValue(quest.RowId, out var items)) { rewards = items; } else { rewards = new List(); itemRewards[quest.RowId] = rewards; } rewards.Add(item); } foreach (var row in quest.OptionalItemReward.Where(item => item.Row != 0)) { var item = row.Value; List rewards; if (itemRewards.TryGetValue(quest.RowId, out var items)) { rewards = items; } else { rewards = new List(); itemRewards[quest.RowId] = rewards; } rewards.Add(item!); } if (quest.ActionReward.Row != 0) { actionRewards[quest.RowId] = quest.ActionReward.Value!; } var instances = this.InstanceUnlocks(quest, linkedInstances); if (instances.Count > 0) { instanceRewards[quest.RowId] = instances; foreach (var instance in instances) { linkedInstances.Add(instance); } } if (quest.BeastTribe.Row != 0 && !quest.IsRepeatable && quest.BeastReputationRank.Row == 0) { beastRewards[quest.RowId] = quest.BeastTribe.Value!; } var jobReward = this.JobUnlocks(quest); if (jobReward != null) { jobRewards[quest.RowId] = jobReward; } } this.ItemRewards = itemRewards; this.EmoteRewards = emoteRewards; this.ActionRewards = actionRewards; this.InstanceRewards = instanceRewards; this.BeastRewards = beastRewards; this.JobRewards = jobRewards; var (_, nodes) = Node.BuildTree(allQuests); this.AllNodes = nodes; } private static readonly Vector2 TextOffset = new(5, 2); internal CancellationTokenSource StartGraphRecalculation(ExcelRow quest) { var cts = new CancellationTokenSource(); new Thread(async () => { var info = this.GetGraphInfo(quest, cts.Token); if (info != null) { await this.GraphChannel.WriteAsync(info, cts.Token); } }).Start(); return cts; } private GraphInfo? GetGraphInfo(ExcelRow quest, CancellationToken cancel) { if (!this.AllNodes.TryGetValue(quest.RowId, out var first)) { return null; } var msaglNodes = new Dictionary(); var links = new List<(uint, uint)>(); var g = new GeometryGraph(); void AddNode(Node node) { if (msaglNodes.ContainsKey(node.Id)) { return; } var dims = ImGui.CalcTextSize(node.Value.Name.ToString()) + TextOffset * 2; var graphNode = new Node(CurveFactory.CreateRectangle(dims.X, dims.Y, new Point()), node.Value); g.Nodes.Add(graphNode); msaglNodes[node.Id] = graphNode; IEnumerable> parents; if (this.Plugin.Config.ShowRedundantArrows) { parents = node.Parents; } else { // only add if no *other* parent also shares parents = node.Parents .Where(q => { return !node.Parents .Where(other => other != q) .Any(other => other.Parents.Contains(q)); }); } foreach (var parent in parents) { links.Add((parent.Id, node.Id)); } } foreach (var node in first.Traverse()) { if (cancel.IsCancellationRequested) { return null; } AddNode(node); } foreach (var node in first.Ancestors(this.ConsolidateMsq)) { if (cancel.IsCancellationRequested) { return null; } AddNode(node); } foreach (var (sourceId, targetId) in links) { if (cancel.IsCancellationRequested) { return null; } if (!msaglNodes.TryGetValue(sourceId, out var source) || !msaglNodes.TryGetValue(targetId, out var target)) { continue; } var edge = new Edge(source, target); if (this.Plugin.Config.ShowArrowheads) { edge.EdgeGeometry = new EdgeGeometry { TargetArrowhead = new Arrowhead(), }; } g.Edges.Add(edge); } LayoutHelpers.CalculateLayout(g, this.LayoutSettings, null); Node? centre = null; if (g.Nodes.Count > 0) { centre = g.Nodes[0]; } return cancel.IsCancellationRequested ? null : new GraphInfo(g, centre); } private Quest? ConsolidateMsq(Quest quest) { if (!this.Plugin.Config.CondenseMsq) { return null; } var name = quest.RowId switch { 66060 => "A Realm Reborn (2.0)", 69414 => "A Realm Awoken (2.1)", 66899 => "Through the Maelstrom (2.2)", 66996 => "Defenders of Eorzea (2.3)", 65625 => "Dreams of Ice (2.4)", 65965 => "Before the Fall - Part 1 (2.5)", 65964 => "Before the Fall - Part 2 (2.55)", 67205 => "Heavensward (3.0)", 67699 => "As Goes Light, So Goes Darkness (3.1)", 67777 => "The Gears of Change (3.2)", 67783 => "Revenge of the Horde (3.3)", 67886 => "Soul Surrender (3.4)", 67891 => "The Far Edge of Fate - Part 1 (3.5)", 67895 => "The Far Edge of Fate - Part 2 (3.56)", 68089 => "Stormblood (4.0)", 68508 => "The Legend Returns (4.1)", 68565 => "Rise of a New Sun (4.2)", 68612 => "Under the Moonlight (4.3)", 68685 => "Prelude in Violet (4.4)", 68719 => "A Requiem for Heroes - Part 1 (4.5)", 68721 => "A Requiem for Heroes - Part 2 (4.56)", 69190 => "Shadowbringers (5.0)", 69218 => "Vows of Virtue, Deeds of Cruelty (5.1)", 69306 => "Echoes of a Fallen Star (5.2)", 69318 => "Reflections in Crystal (5.3)", 69552 => "Futures Rewritten (5.4)", 69599 => "Death Unto Dawn - Part 1 (5.5)", 69602 => "Death Unto Dawn - Part 2 (5.55)", 70000 => "Endwalker (6.0)", _ => null, }; if (name == null) { return null; } var newQuest = new Quest(); foreach (var property in newQuest.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) { property.SetValue(newQuest, property.GetValue(quest)); } newQuest.Name = new Lumina.Text.SeString($"{name} MSQ"); return newQuest; } private HashSet InstanceUnlocks(Quest quest, ICollection others) { if (quest.IsRepeatable) { return new HashSet(); } var unlocks = new HashSet(); if (quest.InstanceContentUnlock.Row != 0) { var cfc = this.Plugin.DataManager.GetExcelSheet()!.FirstOrDefault(cfc => cfc.Content == quest.InstanceContentUnlock.Row && cfc.ContentLinkType == 1); if (cfc != null && cfc.UnlockQuest.Row == 0) { unlocks.Add(cfc); } } var instanceRefs = quest.ScriptInstruction .Zip(quest.ScriptArg, (ins, arg) => (ins, arg)) .Where(x => x.ins.RawString.StartsWith("INSTANCEDUNGEON")); foreach (var reference in instanceRefs) { var key = reference.arg; // var content = this.Plugin.Interface.Data.GetExcelSheet().GetRow(key); var cfc = this.Plugin.DataManager.GetExcelSheet()!.FirstOrDefault(cfc => cfc.Content == key && cfc.ContentLinkType == 1); if (cfc == null || cfc.UnlockQuest.Row != 0 || others.Contains(cfc)) { continue; } if (!quest.ScriptInstruction.Any(i => i.RawString == "UNLOCK_ADD_NEW_CONTENT_TO_CF" || i.RawString.StartsWith("UNLOCK_DUNGEON"))) { if (quest.ScriptInstruction.Any(i => i.RawString.StartsWith("LOC_ITEM"))) { continue; } } unlocks.Add(cfc); } return unlocks; } private ClassJob? JobUnlocks(Quest quest) { if (quest.ClassJobUnlock.Row > 0) { return quest.ClassJobUnlock.Value; } if (quest.ScriptInstruction.All(ins => ins.RawString.StartsWith("UNLOCK_IMAGE_CLASS"))) { return null; } var jobId = quest.ScriptInstruction .Zip(quest.ScriptArg, (ins, arg) => (ins, arg)) .FirstOrDefault(entry => entry.ins.RawString.StartsWith("CLASSJOB")) .arg; return jobId == 0 ? null : this.Plugin.DataManager.GetExcelSheet()!.GetRow(jobId); } } }