commit 919b72051f710c5d4ed51e89bea2d8d1b6fded73 Author: Knack117 Date: Sat Feb 21 18:57:38 2026 -0500 Initial commit: MSQ Progress plugin v1.0.0 Co-authored-by: Cursor 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