From 919b72051f710c5d4ed51e89bea2d8d1b6fded73 Mon Sep 17 00:00:00 2001 From: Knack117 Date: Sat, 21 Feb 2026 18:57:38 -0500 Subject: [PATCH] Initial commit: MSQ Progress plugin v1.0.0 Co-authored-by: Cursor --- .gitignore | 14 ++ DutyUnlockData.json | 1 + DutyUnlockMap.cs | 194 +++++++++++++++++++++++++ MSQDataService.cs | 326 +++++++++++++++++++++++++++++++++++++++++++ MSQProgress.csproj | 26 ++++ MSQProgress.json | 9 ++ MSQProgressPlugin.cs | 62 ++++++++ ProgressWindow.cs | 165 ++++++++++++++++++++++ Service.cs | 15 ++ packages.lock.json | 19 +++ 10 files changed, 831 insertions(+) create mode 100644 .gitignore create mode 100644 DutyUnlockData.json create mode 100644 DutyUnlockMap.cs create mode 100644 MSQDataService.cs create mode 100644 MSQProgress.csproj create mode 100644 MSQProgress.json create mode 100644 MSQProgressPlugin.cs create mode 100644 ProgressWindow.cs create mode 100644 Service.cs create mode 100644 packages.lock.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f7fcac --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Build +[Bb]in/ +[Oo]bj/ +[Dd]ebug/ +[Rr]elease/ +*.user +*.suo +.vs/ +.idea/ +packages/ +*.nupkg +*.snupkg +.DS_Store +Thumbs.db diff --git a/DutyUnlockData.json b/DutyUnlockData.json new file mode 100644 index 0000000..e831e69 --- /dev/null +++ b/DutyUnlockData.json @@ -0,0 +1 @@ +[{"cfc":56,"q":65538},{"cfc":57,"q":65540},{"cfc":58,"q":65542},{"cfc":59,"q":65544},{"cfc":60,"q":65546},{"cfc":61,"q":65548},{"cfc":62,"q":65550},{"cfc":63,"q":65552},{"cfc":64,"q":65554},{"cfc":65,"q":65556},{"cfc":66,"q":65558},{"cfc":67,"q":65560},{"cfc":68,"q":65562},{"cfc":69,"q":65564},{"cfc":70,"q":65566},{"cfc":71,"q":65568},{"cfc":72,"q":65570},{"cfc":73,"q":65572},{"cfc":74,"q":65574},{"cfc":75,"q":65576},{"cfc":76,"q":65578},{"cfc":77,"q":65580},{"cfc":78,"q":65582},{"cfc":79,"q":65584},{"cfc":80,"q":65586},{"cfc":81,"q":65588},{"cfc":82,"q":65590},{"cfc":83,"q":65592},{"cfc":84,"q":65594},{"cfc":85,"q":65596},{"cfc":86,"q":65598},{"cfc":87,"q":65600},{"cfc":88,"q":65602},{"cfc":89,"q":65604},{"cfc":90,"q":65606},{"cfc":91,"q":65608},{"cfc":92,"q":65610},{"cfc":93,"q":65612},{"cfc":94,"q":65614},{"cfc":95,"q":65616},{"cfc":96,"q":65618},{"cfc":97,"q":65620},{"cfc":98,"q":65622},{"cfc":99,"q":65624},{"cfc":100,"q":65626},{"cfc":131,"q":67205},{"cfc":132,"q":67205},{"cfc":133,"q":67205},{"cfc":174,"q":67092},{"cfc":175,"q":67092},{"cfc":176,"q":67092},{"cfc":177,"q":67092},{"cfc":178,"q":67092},{"cfc":181,"q":69206},{"cfc":202,"q":67205},{"cfc":203,"q":67205},{"cfc":204,"q":67923},{"cfc":205,"q":67923},{"cfc":206,"q":67923},{"cfc":207,"q":67923},{"cfc":208,"q":67923},{"cfc":209,"q":67923},{"cfc":210,"q":67923},{"cfc":211,"q":67923},{"cfc":212,"q":67923},{"cfc":213,"q":67923},{"cfc":214,"q":67923},{"cfc":215,"q":67923},{"cfc":216,"q":67923},{"cfc":217,"q":67923},{"cfc":218,"q":67923},{"cfc":225,"q":67205},{"cfc":234,"q":67205},{"cfc":283,"q":68614},{"cfc":473,"q":70190},{"cfc":540,"q":68667},{"cfc":541,"q":68667},{"cfc":542,"q":68667},{"cfc":543,"q":68668},{"cfc":544,"q":68668},{"cfc":545,"q":68668},{"cfc":546,"q":68668},{"cfc":547,"q":68668},{"cfc":548,"q":68668},{"cfc":549,"q":68668},{"cfc":581,"q":68478},{"cfc":598,"q":68148},{"cfc":639,"q":68149},{"cfc":695,"q":69272},{"cfc":696,"q":69272},{"cfc":697,"q":69272},{"cfc":698,"q":69272},{"cfc":699,"q":69272},{"cfc":721,"q":69379},{"cfc":722,"q":69208},{"cfc":730,"q":69379},{"cfc":731,"q":69379},{"cfc":732,"q":69379},{"cfc":733,"q":69379},{"cfc":734,"q":69379},{"cfc":754,"q":69529},{"cfc":756,"q":69566},{"cfc":770,"q":69379},{"cfc":771,"q":69379},{"cfc":772,"q":69379},{"cfc":773,"q":69379},{"cfc":774,"q":69379},{"cfc":775,"q":69379},{"cfc":797,"q":69531},{"cfc":897,"q":70199},{"cfc":898,"q":70199},{"cfc":899,"q":70199},{"cfc":900,"q":70200},{"cfc":901,"q":70200},{"cfc":902,"q":70200},{"cfc":903,"q":70200},{"cfc":904,"q":70200},{"cfc":905,"q":70200},{"cfc":906,"q":70200},{"cfc":948,"q":70313},{"cfc":952,"q":69379},{"cfc":953,"q":69379},{"cfc":954,"q":69379},{"cfc":955,"q":69379},{"cfc":956,"q":69379},{"cfc":957,"q":69379},{"cfc":958,"q":70337},{"cfc":1012,"q":69408},{"cfc":1013,"q":69408},{"cfc":1014,"q":69408},{"cfc":1018,"q":70847},{"cfc":1032,"q":70941},{"cfc":1033,"q":70941},{"cfc":1034,"q":70941},{"cfc":1035,"q":70942},{"cfc":1036,"q":70942},{"cfc":1037,"q":70942},{"cfc":1038,"q":70942},{"cfc":1039,"q":70942},{"cfc":1040,"q":70942},{"cfc":1041,"q":70942},{"cfc":1045,"q":594},{"cfc":1063,"q":70943},{"cfc":1067,"q":20055}] diff --git a/DutyUnlockMap.cs b/DutyUnlockMap.cs new file mode 100644 index 0000000..d9d1d96 --- /dev/null +++ b/DutyUnlockMap.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text.Json; +using Dalamud.Plugin.Services; +using Lumina.Excel.Sheets; + +namespace MSQProgress; + +/// +/// Maps ContentFinderCondition RowId to the Quest RowId that unlocks that duty. +/// Loaded from embedded DutyUnlockData.json (fallback) and then filled from the game's ContentFinderCondition sheet (UnlockQuest column) when DataManager is available, so all duties are covered. +/// +public static class DutyUnlockMap +{ + private static Dictionary? _cfcToQuest; + private static bool _filledFromGame; + private static readonly object Lock = new(); + + public static bool TryGetUnlockQuest(uint contentFinderConditionRowId, out uint questRowId, IDataManager? dataManager = null) + { + EnsureLoaded(dataManager); + if (_cfcToQuest != null && _cfcToQuest.TryGetValue(contentFinderConditionRowId, out var q) && q != 0) + { + questRowId = q; + return true; + } + questRowId = 0; + return false; + } + + private static void EnsureLoaded(IDataManager? dataManager = null) + { + lock (Lock) + { + if (_cfcToQuest == null) + { + _cfcToQuest = new Dictionary(); + try + { + var asm = Assembly.GetExecutingAssembly(); + var name = asm.GetName().Name + ".DutyUnlockData.json"; + using var stream = asm.GetManifestResourceStream(name); + if (stream != null) + { + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + var entries = JsonSerializer.Deserialize>(json); + if (entries != null) + { + foreach (var e in entries) + { + if (e.QuestRowId != 0) + _cfcToQuest[e.CfcRowId] = e.QuestRowId; + } + } + } + } + catch + { + // ignore + } + } + + if (!_filledFromGame && dataManager != null && _cfcToQuest != null) + { + _filledFromGame = true; + try + { + var sheet = dataManager.GetExcelSheet(); + if (sheet != null) + { + foreach (var row in sheet) + { + if (row.RowId == 0) continue; + var q = GetUnlockQuestFromRow(row); + if (q != 0) + _cfcToQuest[row.RowId] = q; + } + } + } + catch + { + _filledFromGame = false; + } + } + } + } + + /// Export current CFC→Quest map to a JSON file (e.g. after loading from game). Use to regenerate DutyUnlockData.json. + public static string? ExportToJsonFile(IDataManager dataManager, string filePath) + { + EnsureLoaded(dataManager); + if (_cfcToQuest == null || _cfcToQuest.Count == 0) return null; + try + { + var entries = new List(); + foreach (var kv in _cfcToQuest) + { + if (kv.Value != 0) + entries.Add(new CfcQuestEntry { cfc = kv.Key, q = kv.Value }); + } + entries.Sort((a, b) => a.CfcRowId.CompareTo(b.CfcRowId)); + var json = JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = false }); + File.WriteAllText(filePath, json); + return filePath; + } + catch + { + return null; + } + } + + internal static uint GetUnlockQuestFromRow(ContentFinderCondition row) + { + var rowType = row.GetType(); + // Prefer known column names + foreach (var propName in new[] { "UnlockQuest", "UnlockCriteria" }) + { + var prop = rowType.GetProperty(propName); + if (prop == null) continue; + try + { + var val = prop.GetValue(row); + if (val == null) continue; + var id = ExtractQuestRowIdFromValue(val); + if (id != 0) return id; + } + catch { /* ignore */ } + } + // Fallback: any property whose name suggests unlock/quest (Lumina may use different names) + foreach (var prop in rowType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var name = prop.Name; + if (!name.Contains("Unlock", StringComparison.OrdinalIgnoreCase) && !name.Contains("Quest", StringComparison.OrdinalIgnoreCase)) + continue; + try + { + var val = prop.GetValue(row); + if (val == null) continue; + var id = ExtractQuestRowIdFromValue(val); + if (id != 0) return id; + } + catch { /* ignore */ } + } + return 0; + } + + /// Try to get a Quest row ID from a value (LazyRow/RowRef/ExcelRow). Handles Lumina 5 structs. + private static uint ExtractQuestRowIdFromValue(object val) + { + var t = val.GetType(); + // Row.RowId (e.g. LazyRow.Row) + if (t.GetProperty("Row", BindingFlags.Public | BindingFlags.Instance) is PropertyInfo rowProp) + { + var inner = rowProp.GetValue(val); + if (inner != null) + { + var innerId = GetRowIdFromObject(inner); + if (innerId != 0) return innerId; + } + } + return GetRowIdFromObject(val); + } + + private static uint GetRowIdFromObject(object obj) + { + if (obj == null) return 0; + var t = obj.GetType(); + if (t.GetProperty("RowId", BindingFlags.Public | BindingFlags.Instance) is PropertyInfo idProp) + { + var v = idProp.GetValue(obj); + if (v is uint u && u != 0) return u; + if (v is int i && i > 0) return (uint)i; + } + var keyProp = t.GetProperty("Key", BindingFlags.Public | BindingFlags.Instance); + if (keyProp != null) + { + var v = keyProp.GetValue(obj); + if (v is uint u && u != 0) return u; + if (v is int i && i > 0) return (uint)i; + } + return 0; + } + + private sealed class CfcQuestEntry + { + public uint cfc { get; set; } + public uint q { get; set; } + public uint CfcRowId => cfc; + public uint QuestRowId => q; + } +} diff --git a/MSQDataService.cs b/MSQDataService.cs new file mode 100644 index 0000000..60a0d59 --- /dev/null +++ b/MSQDataService.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using Lumina.Excel.Sheets; + +namespace MSQProgress; + +/// +/// Builds and caches MSQ progress data from ScenarioTree + Quest + AgentScenarioTree. +/// +public sealed class MSQDataService +{ + private readonly IDataManager _data; + private List? _allSteps; + private Dictionary? _byExpansion; + private int _currentGlobalIndex = -1; + private uint _currentExpansionRowId; + + public MSQDataService(IDataManager data) + { + _data = data; + } + + /// Ordered list of all MSQ steps (by ScenarioTree order). + public IReadOnlyList AllSteps + { + get { _ = EnsureBuilt(); return _allSteps!; } + } + + /// Progress per expansion (key = ExVersion.RowId). + public IReadOnlyDictionary ByExpansion + { + get { _ = EnsureBuilt(); return _byExpansion!; } + } + + /// Current step index in AllSteps, or -1 if unknown. + public int CurrentGlobalIndex => _currentGlobalIndex; + + /// ExVersion.RowId for the expansion the player is currently in. + public uint CurrentExpansionRowId => _currentExpansionRowId; + + private bool EnsureBuilt() + { + if (_byExpansion != null) + return true; + Build(); + return _byExpansion != null; + } + + private void Build() + { + if (_allSteps != null) + return; + + _allSteps = new List(); + _byExpansion = new Dictionary(); + + var scenarioSheet = _data.GetExcelSheet(); + var questSheet = _data.GetExcelSheet(); + if (scenarioSheet == null || questSheet == null) + return; + + var list = new List<(uint Key, ScenarioTree St, Quest Q, ExVersion Ex)>(); + + foreach (var st in scenarioSheet) + { + if (!questSheet.TryGetRow(st.RowId, out var quest) || !quest.Expansion.IsValid) + continue; + var ex = quest.Expansion.Value; + list.Add((st.RowId, st, quest, ex)); + } + + // Order by Unknown2 (scenario order within the game) + list = list.OrderBy(x => x.St.Unknown2).ToList(); + + // Group by (expansion, Unknown2). Collapse into one step when 2+ quests (alternate path: complete if any is complete). + var grouped = list + .GroupBy(x => (ExRowId: x.Ex.RowId, Order: x.St.Unknown2)) + .OrderBy(g => g.Min(x => x.St.Unknown2)) + .ToList(); + + foreach (var group in grouped) + { + var first = group.First(); + var ex = first.Ex; + var exName = ex.Name.ToString(); + if (string.IsNullOrEmpty(exName)) exName = $"Expansion {ex.RowId}"; + + if (!_byExpansion.TryGetValue(ex.RowId, out var prog)) + { + prog = new ExpansionProgress(exName, ex.RowId); + _byExpansion[ex.RowId] = prog; + } + + var questRowIds = group.Select(x => x.Q.RowId).Distinct().ToList(); + var step = new MSQStep(first.St.RowId, first.Q.RowId, ex.RowId, exName, questRowIds); + _allSteps.Add(step); + prog.TotalQuests++; + if (group.Any(x => QuestManager.IsQuestComplete(x.St.RowId))) + prog.CompletedQuests++; + } + + // Apply wiki totals for display: total = wiki count when known; scale completed proportionally so e.g. ARR shows 241/241 when done + foreach (var prog in _byExpansion.Values) + { + var stepsTotal = prog.TotalQuests; + prog.StepsTotal = stepsTotal; + var wikiTotal = GetWikiTotalForExpansion(prog.Name, prog.ExpansionRowId); + if (wikiTotal is int total) + { + prog.TotalQuests = total; + if (stepsTotal > 0) + prog.CompletedQuests = (int)Math.Round((double)prog.CompletedQuests / stepsTotal * total); + } + } + + // Set current expansion and index from agent + UpdateCurrentFromAgent(); + } + + private void UpdateCurrentFromAgent() + { + _currentGlobalIndex = -1; + _currentExpansionRowId = 0; + + unsafe + { + var agent = AgentScenarioTree.Instance(); + if (agent == null || agent->Data == null) + return; + + var data = (byte*)agent->Data; + if (data == null) return; + // Prefer current MSQ (offset 0); fall back to last completed (offset 6) so "quests until unlock" uses the quest we're on. + ushort index = *(ushort*)(data + 0); + if (index == 0) + index = *(ushort*)(data + 6); + if (index == 0) + return; + + var scenarioSheet = _data.GetExcelSheet(); + if (scenarioSheet == null) + return; + + var row = scenarioSheet.GetRow(index | 0x10000U); + if (row.RowId == 0) + return; + + var questSheet = _data.GetExcelSheet(); + if (!questSheet.TryGetRow(row.RowId, out var quest)) + return; + + _currentExpansionRowId = quest.Expansion.IsValid ? quest.Expansion.Value.RowId : 0; + + for (int i = 0; i < _allSteps!.Count; i++) + { + if (_allSteps[i].ContainsQuest(row.RowId)) + { + _currentGlobalIndex = i; + break; + } + } + } + } + + /// Call when game state may have changed (e.g. after completing a quest). Refreshes completion counts and current index. + public void Refresh() + { + if (_allSteps == null || _byExpansion == null) + return; + + foreach (var prog in _byExpansion.Values) + prog.CompletedQuests = 0; + + foreach (var step in _allSteps) + { + if (step.IsAnyQuestComplete() && _byExpansion.TryGetValue(step.ExpansionRowId, out var prog)) + prog.CompletedQuests++; + } + + // Scale completed to wiki total when we use wiki totals (so e.g. 218/218 steps → 241/241 display for ARR) + foreach (var prog in _byExpansion.Values) + { + if (prog.StepsTotal <= 0) continue; + var completedSteps = prog.CompletedQuests; + prog.CompletedQuests = (int)Math.Round((double)completedSteps / prog.StepsTotal * prog.TotalQuests); + } + + UpdateCurrentFromAgent(); + } + + /// Index in AllSteps of the first step matching the given quest, or -1. + public int GetStepIndexForQuest(uint questRowId) + { + var steps = AllSteps; + for (int i = 0; i < steps.Count; i++) + { + if (steps[i].ContainsQuest(questRowId)) + return i; + } + return -1; + } + + /// Number of MSQ quests from current position until the given quest is reached (counts quests in each step, not steps). Returns -1 if quest not in MSQ or already past. + public int QuestsUntilQuest(uint questRowId) + { + var steps = AllSteps; + if (_currentGlobalIndex < 0) + return -1; + int count = 0; + for (int i = _currentGlobalIndex; i < steps.Count; i++) + { + count += steps[i].QuestCount; + if (steps[i].ContainsQuest(questRowId)) + return count; + } + return -1; + } + + /// Number of MSQ quests from the start (step 0) until the given quest. Used for "at least X quests" when current position is unknown. + public int QuestsFromStartToQuest(uint questRowId) + { + var steps = AllSteps; + int count = 0; + for (int i = 0; i < steps.Count; i++) + { + count += steps[i].QuestCount; + if (steps[i].ContainsQuest(questRowId)) + return count; + } + return -1; + } + + public sealed class MSQStep + { + public uint ScenarioTreeRowId { get; } + public uint QuestRowId { get; } + public uint ExpansionRowId { get; } + public string ExpansionName { get; } + private readonly uint[] _questRowIds; + + public MSQStep(uint scenarioTreeRowId, uint questRowId, uint expansionRowId, string expansionName, List questRowIds) + { + ScenarioTreeRowId = scenarioTreeRowId; + QuestRowId = questRowId; + ExpansionRowId = expansionRowId; + ExpansionName = expansionName; + _questRowIds = questRowIds?.ToArray() ?? new[] { questRowId }; + } + + /// True if this step includes the given quest (handles 0x10000 vs raw format). + public bool ContainsQuest(uint questRowId) + { + foreach (var id in _questRowIds) + { + if (StepsMatchQuest(id, questRowId)) return true; + } + return false; + } + + private static bool StepsMatchQuest(uint stepQuestRowId, uint questRowId) + { + if (stepQuestRowId == questRowId) return true; + if (questRowId < 0x10000 && (stepQuestRowId == (questRowId | 0x10000U))) return true; + if (questRowId >= 0x10000 && (stepQuestRowId == (questRowId & 0xFFFF))) return true; + return false; + } + + /// True if the player has completed any quest in this step (used for branch paths). + public bool IsAnyQuestComplete() + { + foreach (var id in _questRowIds) + { + if (QuestManager.IsQuestComplete(id)) return true; + } + return false; + } + + /// Number of quests in this step (1 for linear, 2+ for alternate-path group). Used for "X quests until unlock" count. + public int QuestCount => _questRowIds.Length; + } + + public sealed class ExpansionProgress + { + public string Name { get; } + public uint ExpansionRowId { get; } + /// Total quest count for display (wiki-aligned when available). + public int TotalQuests { get; set; } + public int CompletedQuests { get; set; } + public int RemainingQuests => TotalQuests - CompletedQuests; + /// Number of MSQ steps we have for this expansion (used to scale completed to wiki total). + public int StepsTotal { get; set; } + + public ExpansionProgress(string name, uint expansionRowId) + { + Name = name; + ExpansionRowId = expansionRowId; + } + } + + /// MSQ quest counts per expansion from https://ffxiv.consolegameswiki.com/wiki/Main_Scenario_Quests (single-path). + private static int? GetWikiTotalForExpansion(string expansionName, uint expansionRowId) + { + // RowId fallback for non-English clients (ExVersion.Name is localized) + if (expansionRowId <= 5) + { + var byRowId = new[] { 241, 138, 162, 157, 155, 134 }; + return byRowId[expansionRowId]; + } + if (string.IsNullOrEmpty(expansionName)) return null; + var name = expansionName.Trim(); + return name switch + { + "A Realm Reborn" => 241, // 160-161 (2.0) + 80 (2.1-2.55) + "Heavensward" => 138, // 94 + 25 + 19 + "Stormblood" => 162, // 122 + 40 + "Shadowbringers" => 157, // 106 + 32 + 19 + "Endwalker" => 155, // 108 + 47 + "Dawntrail" => 134, // 100 + 25 + 9 + _ => null + }; + } +} diff --git a/MSQProgress.csproj b/MSQProgress.csproj new file mode 100644 index 0000000..a478e72 --- /dev/null +++ b/MSQProgress.csproj @@ -0,0 +1,26 @@ + + + x64 + net10.0-windows + latest + x64 + Debug;Release + + + Library + false + false + true + + + MSQProgress + 1.0.0.0 + + + $(appdata)\XIVLauncher\addon\Hooks\dev\ + + + + + + diff --git a/MSQProgress.json b/MSQProgress.json new file mode 100644 index 0000000..ac824af --- /dev/null +++ b/MSQProgress.json @@ -0,0 +1,9 @@ +{ + "Author": "You", + "Name": "MSQ Progress", + "InternalName": "MSQProgress", + "Description": "Track Main Scenario Quest progress: completed and remaining per expansion, and how many quests until a duty or trial unlock.", + "ApplicableVersion": "any", + "Tags": ["quest", "MSQ", "progress", "story"], + "Punchline": "See how many MSQ you've done and how many until the next duty." +} diff --git a/MSQProgressPlugin.cs b/MSQProgressPlugin.cs new file mode 100644 index 0000000..5de42ea --- /dev/null +++ b/MSQProgressPlugin.cs @@ -0,0 +1,62 @@ +using System; +using Dalamud.Game.Command; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; + +namespace MSQProgress; + +public sealed class MSQProgressPlugin : IDalamudPlugin +{ + private const string Command = "/msqprogress"; + private readonly WindowSystem _windowSystem; + private readonly ProgressWindow _progressWindow; + + public MSQProgressPlugin(IDalamudPluginInterface pluginInterface) + { + pluginInterface.Create(); + _progressWindow = new ProgressWindow(); + _windowSystem = new WindowSystem("MSQProgress"); + _windowSystem.AddWindow(_progressWindow); + + pluginInterface.UiBuilder.OpenConfigUi += OnOpenConfigUi; + pluginInterface.UiBuilder.Draw += _windowSystem.Draw; + + Service.CommandManager.AddHandler(Command, new CommandInfo(OnCommand) + { + HelpMessage = "Open MSQ Progress. Use 'exportdutyunlock' to export CFC→UnlockQuest map to DutyUnlockData.json.", + ShowInHelp = true, + }); + } + + public string Name => "MSQ Progress"; + + public void Dispose() + { + Service.CommandManager.RemoveHandler(Command); + Service.Interface.UiBuilder.OpenConfigUi -= OnOpenConfigUi; + Service.Interface.UiBuilder.Draw -= _windowSystem.Draw; + } + + private void OnOpenConfigUi() => _progressWindow.Toggle(); + + private void OnCommand(string command, string args) + { + if (string.Equals(args.Trim(), "exportdutyunlock", StringComparison.OrdinalIgnoreCase)) + { + OnExportDutyUnlock(command, args); + return; + } + _progressWindow.Toggle(); + } + + private void OnExportDutyUnlock(string command, string args) + { + var path = System.IO.Path.Combine(Service.Interface.AssemblyLocation.DirectoryName ?? "", "DutyUnlockData.json"); + var result = DutyUnlockMap.ExportToJsonFile(Service.DataManager, path); + if (result != null) + Service.PluginLog.Info($"Exported duty unlock map to {result}"); + else + Service.PluginLog.Warning("Export failed or map empty."); + } +} diff --git a/ProgressWindow.cs b/ProgressWindow.cs new file mode 100644 index 0000000..dc41d6c --- /dev/null +++ b/ProgressWindow.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Windowing; +using Lumina.Excel.Sheets; +using CfcType = FFXIVClientStructs.FFXIV.Client.Game.InstanceContent.InstanceContentType; + +namespace MSQProgress; + +public class ProgressWindow : Window +{ + private readonly MSQDataService _dataService; + private int _drawCount; + + public ProgressWindow() : base("MSQ Progress", ImGuiWindowFlags.AlwaysAutoResize) + { + _dataService = new MSQDataService(Service.DataManager); + } + + public override void Draw() + { + if (!Service.ClientState.IsLoggedIn) + { + ImGui.TextColored(new Vector4(1, 0.8f, 0.4f, 1), "Log in to see your MSQ progress."); + return; + } + + _drawCount++; + if (_drawCount % 30 == 0) + _dataService.Refresh(); + + if (ImGui.Button("Refresh")) + _dataService.Refresh(); + + ImGui.SameLine(); + ImGui.TextDisabled("(Progress updates automatically; use Refresh if needed)"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.Text("Progress by expansion"); + ImGui.TextDisabled("(Each expansion = base + all patch MSQ, e.g. ARR = 2.0 + 2.1–2.55)"); + ImGui.Spacing(); + + var byExpansion = _dataService.ByExpansion; + if (byExpansion.Count == 0) + { + ImGui.TextColored(new Vector4(0.8f, 0.6f, 0.2f, 1), "No MSQ data loaded. Try refreshing after opening the MSQ journal once."); + return; + } + + if (ImGui.BeginTable("msq_expansions", 4, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg)) + { + ImGui.TableSetupColumn("Expansion", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Completed", ImGuiTableColumnFlags.WidthFixed, 80); + ImGui.TableSetupColumn("Total", ImGuiTableColumnFlags.WidthFixed, 60); + ImGui.TableSetupColumn("Remaining", ImGuiTableColumnFlags.WidthFixed, 80); + ImGui.TableHeadersRow(); + + foreach (var kv in byExpansion.OrderBy(x => x.Key)) + { + var prog = kv.Value; + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + var isCurrent = kv.Key == _dataService.CurrentExpansionRowId; + if (isCurrent) + ImGui.TextColored(new Vector4(0.4f, 0.9f, 0.5f, 1), $"{prog.Name} (current)"); + else + ImGui.Text(prog.Name); + ImGui.TableSetColumnIndex(1); + ImGui.Text(prog.CompletedQuests.ToString()); + ImGui.TableSetColumnIndex(2); + ImGui.Text(prog.TotalQuests.ToString()); + ImGui.TableSetColumnIndex(3); + ImGui.Text(prog.RemainingQuests.ToString()); + } + ImGui.EndTable(); + } + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + var cfcSheet = Service.DataManager.GetExcelSheet(); + if (cfcSheet != null) + { + var (until, fromStart) = GetQuestsUntilNextInstanceUnlock(cfcSheet); + var currentIndex = _dataService.CurrentGlobalIndex; + if (until >= 0) + ImGui.TextColored(new Vector4(0.6f, 0.85f, 1f, 1), $"{until} Quests remaining until next instance unlock"); + else if (fromStart >= 0) + ImGui.TextColored(new Vector4(0.6f, 0.85f, 1f, 1), $"At least {fromStart} quests until next instance unlock (open MSQ journal + Refresh for exact count)"); + else if (currentIndex >= 0) + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.6f, 1), "Next instance unlock unknown (open MSQ journal and Refresh)."); + else + ImGui.TextColored(new Vector4(0.5f, 0.7f, 0.5f, 1), "No instance unlocks remaining (or open MSQ journal and Refresh)."); + } + else + ImGui.TextDisabled("Duty data unavailable."); + } + + /// Returns (quests until next instance, from-start fallback). Uses MSQ order only (ignores game unlock state) so we always show the next instance in the story. + private (int until, int fromStart) GetQuestsUntilNextInstanceUnlock(IEnumerable cfcSheet) + { + int minUntil = -1; + int minFromStart = -1; + var currentIndex = _dataService.CurrentGlobalIndex; + foreach (var cfc in GetDutiesForDisplay(cfcSheet)) + { + if (!DutyUnlockMap.TryGetUnlockQuest(cfc.RowId, out var unlockQuestId, Service.DataManager)) + unlockQuestId = DutyUnlockMap.GetUnlockQuestFromRow(cfc); + if (unlockQuestId == 0) + continue; + var unlockStepIndex = _dataService.GetStepIndexForQuest(unlockQuestId); + // Skip if we're past this unlock (we've completed that step) + if (currentIndex >= 0 && unlockStepIndex >= 0 && unlockStepIndex < currentIndex) + continue; + + int until; + if (currentIndex >= 0 && unlockStepIndex >= 0 && unlockStepIndex == currentIndex) + until = 0; // we're on the unlock quest right now + else + until = _dataService.QuestsUntilQuest(unlockQuestId); + + var fromStart = _dataService.QuestsFromStartToQuest(unlockQuestId); + if (until >= 0 && (minUntil < 0 || until < minUntil)) + minUntil = until; + if (fromStart >= 0 && (minFromStart < 0 || fromStart < minFromStart)) + minFromStart = fromStart; + } + return (minUntil, minFromStart); + } + + private static IEnumerable GetDutiesForDisplay(IEnumerable sheet) + { + var dungeon = (uint)CfcType.Dungeon; + var trial = (uint)CfcType.Trial; + var raid = (uint)CfcType.Raid; + foreach (var row in sheet) + { + if (!row.ContentType.IsValid) continue; + var typeId = row.ContentType.RowId; + if (typeId == dungeon || typeId == trial || typeId == raid) + yield return row; + } + } + + private static string GetContentTypeLabel(ContentFinderCondition cfc) + { + if (!cfc.ContentType.IsValid) + return "?"; + var typeId = cfc.ContentType.RowId; + return typeId switch + { + (uint)CfcType.Dungeon => "Dungeon", + (uint)CfcType.Trial => "Trial", + (uint)CfcType.Raid => "Raid", + (uint)CfcType.GuildOrder => "Guildhest", + _ => "?" + }; + } +} diff --git a/Service.cs b/Service.cs new file mode 100644 index 0000000..08f64c3 --- /dev/null +++ b/Service.cs @@ -0,0 +1,15 @@ +using Dalamud.IoC; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; + +namespace MSQProgress; + +internal class Service +{ + [PluginService] internal static IDalamudPluginInterface Interface { get; private set; } = null!; + [PluginService] internal static IClientState ClientState { get; private set; } = null!; + [PluginService] internal static ICommandManager CommandManager { get; private set; } = null!; + [PluginService] internal static IDataManager DataManager { get; private set; } = null!; + [PluginService] internal static IPluginLog PluginLog { get; private set; } = null!; + [PluginService] internal static IUnlockState UnlockState { get; private set; } = null!; +} diff --git a/packages.lock.json b/packages.lock.json new file mode 100644 index 0000000..d9dfac0 --- /dev/null +++ b/packages.lock.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "DalamudPackager": { + "type": "Direct", + "requested": "[14.0.1, )", + "resolved": "14.0.1", + "contentHash": "y0WWyUE6dhpGdolK3iKgwys05/nZaVf4ZPtIjpLhJBZvHxkkiE23zYRo7K7uqAgoK/QvK5cqF6l3VG5AbgC6KA==" + }, + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.39, )", + "resolved": "1.2.39", + "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" + } + } + } +} \ No newline at end of file