919b72051f
Co-authored-by: Cursor <cursoragent@cursor.com>
327 lines
12 KiB
C#
327 lines
12 KiB
C#
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
|
|
};
|
|
}
|
|
}
|