Initial commit: MSQ Progress plugin v1.0.0
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
# Build
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Dd]ebug/
|
||||
[Rr]elease/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
.idea/
|
||||
packages/
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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}]
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class DutyUnlockMap
|
||||
{
|
||||
private static Dictionary<uint, uint>? _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<uint, uint>();
|
||||
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<List<CfcQuestEntry>>(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<ContentFinderCondition>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Export current CFC→Quest map to a JSON file (e.g. after loading from game). Use to regenerate DutyUnlockData.json.</summary>
|
||||
public static string? ExportToJsonFile(IDataManager dataManager, string filePath)
|
||||
{
|
||||
EnsureLoaded(dataManager);
|
||||
if (_cfcToQuest == null || _cfcToQuest.Count == 0) return null;
|
||||
try
|
||||
{
|
||||
var entries = new List<CfcQuestEntry>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Try to get a Quest row ID from a value (LazyRow/RowRef/ExcelRow). Handles Lumina 5 structs.</summary>
|
||||
private static uint ExtractQuestRowIdFromValue(object val)
|
||||
{
|
||||
var t = val.GetType();
|
||||
// Row.RowId (e.g. LazyRow<Quest>.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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds and caches MSQ progress data from ScenarioTree + Quest + AgentScenarioTree.
|
||||
/// </summary>
|
||||
public sealed class MSQDataService
|
||||
{
|
||||
private readonly IDataManager _data;
|
||||
private List<MSQStep>? _allSteps;
|
||||
private Dictionary<uint, ExpansionProgress>? _byExpansion;
|
||||
private int _currentGlobalIndex = -1;
|
||||
private uint _currentExpansionRowId;
|
||||
|
||||
public MSQDataService(IDataManager data)
|
||||
{
|
||||
_data = data;
|
||||
}
|
||||
|
||||
/// <summary>Ordered list of all MSQ steps (by ScenarioTree order).</summary>
|
||||
public IReadOnlyList<MSQStep> AllSteps
|
||||
{
|
||||
get { _ = EnsureBuilt(); return _allSteps!; }
|
||||
}
|
||||
|
||||
/// <summary>Progress per expansion (key = ExVersion.RowId).</summary>
|
||||
public IReadOnlyDictionary<uint, ExpansionProgress> ByExpansion
|
||||
{
|
||||
get { _ = EnsureBuilt(); return _byExpansion!; }
|
||||
}
|
||||
|
||||
/// <summary>Current step index in AllSteps, or -1 if unknown.</summary>
|
||||
public int CurrentGlobalIndex => _currentGlobalIndex;
|
||||
|
||||
/// <summary>ExVersion.RowId for the expansion the player is currently in.</summary>
|
||||
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<MSQStep>();
|
||||
_byExpansion = new Dictionary<uint, ExpansionProgress>();
|
||||
|
||||
var scenarioSheet = _data.GetExcelSheet<ScenarioTree>();
|
||||
var questSheet = _data.GetExcelSheet<Quest>();
|
||||
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<ScenarioTree>();
|
||||
if (scenarioSheet == null)
|
||||
return;
|
||||
|
||||
var row = scenarioSheet.GetRow(index | 0x10000U);
|
||||
if (row.RowId == 0)
|
||||
return;
|
||||
|
||||
var questSheet = _data.GetExcelSheet<Quest>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Call when game state may have changed (e.g. after completing a quest). Refreshes completion counts and current index.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Index in AllSteps of the first step matching the given quest, or -1.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Number of MSQ quests from the start (step 0) until the given quest. Used for "at least X quests" when current position is unknown.</summary>
|
||||
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<uint> questRowIds)
|
||||
{
|
||||
ScenarioTreeRowId = scenarioTreeRowId;
|
||||
QuestRowId = questRowId;
|
||||
ExpansionRowId = expansionRowId;
|
||||
ExpansionName = expansionName;
|
||||
_questRowIds = questRowIds?.ToArray() ?? new[] { questRowId };
|
||||
}
|
||||
|
||||
/// <summary>True if this step includes the given quest (handles 0x10000 vs raw format).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>True if the player has completed any quest in this step (used for branch paths).</summary>
|
||||
public bool IsAnyQuestComplete()
|
||||
{
|
||||
foreach (var id in _questRowIds)
|
||||
{
|
||||
if (QuestManager.IsQuestComplete(id)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Number of quests in this step (1 for linear, 2+ for alternate-path group). Used for "X quests until unlock" count.</summary>
|
||||
public int QuestCount => _questRowIds.Length;
|
||||
}
|
||||
|
||||
public sealed class ExpansionProgress
|
||||
{
|
||||
public string Name { get; }
|
||||
public uint ExpansionRowId { get; }
|
||||
/// <summary>Total quest count for display (wiki-aligned when available).</summary>
|
||||
public int TotalQuests { get; set; }
|
||||
public int CompletedQuests { get; set; }
|
||||
public int RemainingQuests => TotalQuests - CompletedQuests;
|
||||
/// <summary>Number of MSQ steps we have for this expansion (used to scale completed to wiki total).</summary>
|
||||
public int StepsTotal { get; set; }
|
||||
|
||||
public ExpansionProgress(string name, uint expansionRowId)
|
||||
{
|
||||
Name = name;
|
||||
ExpansionRowId = expansionRowId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>MSQ quest counts per expansion from https://ffxiv.consolegameswiki.com/wiki/Main_Scenario_Quests (single-path).</summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Dalamud.NET.Sdk/14.0.1">
|
||||
<PropertyGroup Label="Target">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Platforms>x64</Platforms>
|
||||
<Configurations>Debug;Release</Configurations>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Label="Build">
|
||||
<OutputType>Library</OutputType>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendPlatformToOutputPath>false</AppendPlatformToOutputPath>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<AssemblyName>MSQProgress</AssemblyName>
|
||||
<Version>1.0.0.0</Version>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<DalamudLibPath>$(appdata)\XIVLauncher\addon\Hooks\dev\</DalamudLibPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="MSQProgress.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="DutyUnlockData.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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<Service>();
|
||||
_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.");
|
||||
}
|
||||
}
|
||||
@@ -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<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",
|
||||
_ => "?"
|
||||
};
|
||||
}
|
||||
}
|
||||
+15
@@ -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!;
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user