Initial commit: MSQ Progress plugin v1.0.0

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-21 18:57:38 -05:00
commit 919b72051f
10 changed files with 831 additions and 0 deletions
+165
View File
@@ -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.12.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<ContentFinderCondition>();
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.");
}
/// <summary>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.</summary>
private (int until, int fromStart) GetQuestsUntilNextInstanceUnlock(IEnumerable<ContentFinderCondition> 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<ContentFinderCondition> GetDutiesForDisplay(IEnumerable<ContentFinderCondition> 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",
_ => "?"
};
}
}