Initial commit: MSQ Progress plugin v1.0.0
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user