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