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
};
}
}