bd9164c896
Made-with: Cursor
1051 lines
50 KiB
C#
1051 lines
50 KiB
C#
using FFXIVClientStructs.FFXIV.Client.Game;
|
|
using FFXIVClientStructs.FFXIV.Client.Game.UI;
|
|
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
|
using FFXIVClientStructs.FFXIV.Client.UI.Arrays;
|
|
using FFXIVClientStructs.FFXIV.Component.GUI;
|
|
using Dalamud.Game.ClientState.Conditions;
|
|
using Dalamud.Utility;
|
|
using Lumina.Excel;
|
|
using Lumina.Excel.Sheets;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using System.Runtime.CompilerServices;
|
|
|
|
namespace HSUI.Helpers
|
|
{
|
|
public static class DutyListScenarioHelper
|
|
{
|
|
private const uint DefaultQuestIconId = 61801;
|
|
|
|
public record DutyListEntry(ushort QuestId, byte Sequence, string QuestName, string ObjectiveText, uint IconId);
|
|
public record ScenarioGuideEntry(string Label, string QuestName, uint IconId, ushort Level, uint QuestRowId);
|
|
/// <summary>Duty Finder queue status when InDutyQueue.</summary>
|
|
public record DutyFinderQueueInfo(
|
|
string DutyName,
|
|
string StatusText,
|
|
byte TanksFound, byte TanksNeeded,
|
|
byte HealersFound, byte HealersNeeded,
|
|
byte DPSFound, byte DPSNeeded,
|
|
TimeSpan TimeElapsed,
|
|
string? AverageWaitText);
|
|
|
|
/// <summary>In-duty information when inside a dungeon/trial/raid (BoundByDuty, not in queue).</summary>
|
|
public record DutyInfo(
|
|
string DutyName,
|
|
string TimerText,
|
|
IReadOnlyList<DutyObjectiveEntry> Objectives);
|
|
|
|
/// <summary>Single duty objective line, e.g. "Bear witness to the first doom: 0/1".</summary>
|
|
public record DutyObjectiveEntry(string Text, string Progress);
|
|
|
|
/// <summary>Resolves the quest icon ID for duty list/journal display, preferring EventIconType over Quest.Icon.</summary>
|
|
private static uint GetQuestIconId(Quest questData)
|
|
{
|
|
try
|
|
{
|
|
if (questData.EventIconType.RowId != 0)
|
|
{
|
|
var eventIcon = questData.EventIconType.Value;
|
|
if (eventIcon.MapIconAvailable != 0)
|
|
{
|
|
uint offset = questData.IsRepeatable ? 2u : 1u;
|
|
return eventIcon.MapIconAvailable + offset;
|
|
}
|
|
}
|
|
if (questData.Icon != 0)
|
|
return (uint)questData.Icon;
|
|
}
|
|
catch { /* ignore */ }
|
|
return DefaultQuestIconId;
|
|
}
|
|
|
|
/// <summary>Gets quest level (LevelMax or ClassJobLevel) for "Lv. X ???" display.</summary>
|
|
private static ushort GetQuestLevel(Quest questData)
|
|
{
|
|
if (questData.LevelMax != 0) return questData.LevelMax;
|
|
return questData.ClassJobLevel.FirstOrDefault();
|
|
}
|
|
|
|
/// <summary>Returns active duty list entries (quests with objectives).</summary>
|
|
public static unsafe List<DutyListEntry> GetDutyListEntries()
|
|
{
|
|
var result = new List<DutyListEntry>();
|
|
try
|
|
{
|
|
var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
|
|
if (questSheet == null) return result;
|
|
|
|
var span = QuestManager.Instance()->NormalQuests;
|
|
for (int i = 0; i < span.Length; i++)
|
|
{
|
|
ref var q = ref span[i];
|
|
if (q.QuestId == 0) continue;
|
|
|
|
uint rowId = q.QuestId + 65536u;
|
|
if (!questSheet.HasRow(rowId)) continue;
|
|
|
|
var questData = questSheet.GetRow(rowId);
|
|
|
|
string questName = questData.Name.ExtractText();
|
|
if (string.IsNullOrWhiteSpace(questName)) questName = "???";
|
|
uint iconId = GetQuestIconId(questData);
|
|
|
|
// Current objective text from Sequence
|
|
string objectiveText = GetObjectiveText(questData, q.Sequence);
|
|
|
|
result.Add(new DutyListEntry(q.QuestId, q.Sequence, questName, objectiveText, iconId));
|
|
}
|
|
|
|
// If Lumina didn't give objective text, try the game's ToDoList string array (same source as the default Duty List UI).
|
|
TryFillObjectivesFromToDoListStringArray(result);
|
|
// Do not read from _ToDoList addon text nodes: addon memory can be invalid when Duty Finder is open or when queuing, causing AccessViolationException in Utf8String.ToString().
|
|
|
|
// Levequests
|
|
var leveSpan = QuestManager.Instance()->LeveQuests;
|
|
var leveSheet = Plugin.DataManager.GetExcelSheet<Leve>();
|
|
if (leveSheet != null)
|
|
{
|
|
for (int i = 0; i < leveSpan.Length; i++)
|
|
{
|
|
ref var lq = ref leveSpan[i];
|
|
if (lq.LeveId == 0) continue;
|
|
|
|
var leveData = leveSheet.GetRow(lq.LeveId);
|
|
|
|
string leveName = leveData.Name.ExtractText();
|
|
if (string.IsNullOrWhiteSpace(leveName)) leveName = "Levequest";
|
|
uint leveIcon = 61801; // default leve/quest icon
|
|
|
|
result.Add(new DutyListEntry(0, 0, leveName, "", leveIcon));
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Plugin.Logger.Verbose($"[DutyListScenario] GetDutyListEntries: {ex.Message}");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Gets the current objective description for a quest step from Lumina TodoParams (field name may vary by version).</summary>
|
|
private static string GetObjectiveText(Quest questData, byte sequence)
|
|
{
|
|
try
|
|
{
|
|
foreach (var todo in questData.TodoParams)
|
|
{
|
|
if (todo.ToDoCompleteSeq != sequence) continue;
|
|
string? text = TryGetTodoTextViaReflection(todo);
|
|
if (!string.IsNullOrWhiteSpace(text)) return text;
|
|
break;
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
return "";
|
|
}
|
|
|
|
/// <summary>Tries to get objective text by reflecting on TodoParamsStruct for a property with ExtractText().</summary>
|
|
private static string? TryGetTodoTextViaReflection(Quest.TodoParamsStruct todo)
|
|
{
|
|
var type = todo.GetType();
|
|
foreach (var prop in type.GetProperties())
|
|
{
|
|
try
|
|
{
|
|
var val = prop.GetValue(todo);
|
|
if (val == null) continue;
|
|
var vType = val.GetType();
|
|
var extractMethod = vType.GetMethod("ExtractText", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance, null, Type.EmptyTypes, null);
|
|
if (extractMethod != null)
|
|
{
|
|
var result = extractMethod.Invoke(val, null) as string;
|
|
if (!string.IsNullOrWhiteSpace(result)) return result;
|
|
}
|
|
}
|
|
catch { /* skip this property */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Returns true when Duty Finder (ContentsFinder) is open—avoid reading _ToDoList then (addon memory can be invalid). Party Finder is left allowed so duty list can still be read.</summary>
|
|
private static bool IsDutyOrPartyFinderOpen()
|
|
{
|
|
try
|
|
{
|
|
if (Plugin.GameGui.GetAddonByName("ContentsFinder", 0).Address != nint.Zero) return true;
|
|
if (Plugin.GameGui.GetAddonByName("ContentsFinder", 1).Address != nint.Zero) return true;
|
|
if (Plugin.GameGui.GetAddonByName("ContentsFinderConfirm", 0).Address != nint.Zero) return true;
|
|
}
|
|
catch { /* ignore */ }
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Returns true when we should skip reading _ToDoList (Duty Finder open, in queue, or zone transition—addon memory can be invalid).</summary>
|
|
private static unsafe bool ShouldSkipReadingDutyListAddon()
|
|
{
|
|
if (IsDutyOrPartyFinderOpen()) return true;
|
|
try
|
|
{
|
|
if (Conditions.Instance() != null && Conditions.Instance()->InDutyQueue) return true;
|
|
if (Plugin.Condition[ConditionFlag.BetweenAreas]) return true;
|
|
if (Plugin.Condition[ConditionFlag.BetweenAreas51]) return true;
|
|
}
|
|
catch { /* ignore */ }
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Read objective text from the game's ToDoList string array (same data the default Duty List UI uses). Layout: titles at 0..QuestCount-1, details at QuestCount..2*QuestCount-1. Pairs only by quest name (or fuzzy match) so we never assign the wrong objective when array order differs from NormalQuests.</summary>
|
|
private static unsafe void TryFillObjectivesFromToDoListStringArray(List<DutyListEntry> result)
|
|
{
|
|
if (result.Count == 0) return;
|
|
try
|
|
{
|
|
var stringArray = ToDoListStringArray.Instance();
|
|
var numberArray = ToDoListNumberArray.Instance();
|
|
if (stringArray == null || numberArray == null) return;
|
|
|
|
int questCount = numberArray->QuestCount;
|
|
if (questCount <= 0 || questCount > 30) return;
|
|
|
|
// _questTexts at offset 9*8, 60 CStringPointer (8 bytes each). Titles 0..questCount-1, details questCount..2*questCount-1.
|
|
const int questTextsOffset = 9 * 8;
|
|
byte* baseAddr = (byte*)stringArray;
|
|
|
|
string ReadSlot(int index)
|
|
{
|
|
if (index < 0 || index >= 60) return "";
|
|
byte* ptr = *(byte**)(baseAddr + questTextsOffset + index * 8);
|
|
if (ptr == null) return "";
|
|
try { return Marshal.PtrToStringUTF8((IntPtr)ptr) ?? ""; }
|
|
catch { return ""; }
|
|
}
|
|
|
|
for (int i = 0; i < result.Count; i++)
|
|
{
|
|
var entry = result[i];
|
|
string obj = "";
|
|
int titleIdx = -1;
|
|
var questNameTrim = entry.QuestName.Trim();
|
|
if (string.IsNullOrWhiteSpace(questNameTrim)) continue;
|
|
|
|
// Exact match first (case-insensitive)
|
|
for (int j = 0; j < questCount; j++)
|
|
{
|
|
var title = ReadSlot(j).Trim();
|
|
if (string.Equals(title, questNameTrim, StringComparison.OrdinalIgnoreCase))
|
|
{ titleIdx = j; break; }
|
|
}
|
|
|
|
// Fuzzy match for truncated/abbreviated names: game may shorten the title in the array
|
|
if (titleIdx < 0)
|
|
{
|
|
for (int j = 0; j < questCount; j++)
|
|
{
|
|
var title = ReadSlot(j).Trim();
|
|
if (string.IsNullOrWhiteSpace(title)) continue;
|
|
if (title.StartsWith(questNameTrim, StringComparison.OrdinalIgnoreCase) ||
|
|
questNameTrim.StartsWith(title, StringComparison.OrdinalIgnoreCase))
|
|
{ titleIdx = j; break; }
|
|
}
|
|
}
|
|
|
|
if (titleIdx >= 0)
|
|
obj = ReadSlot(questCount + titleIdx).Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(obj)) continue;
|
|
result[i] = entry with { ObjectiveText = obj };
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Plugin.Logger.Verbose($"[DutyListScenario] TryFillObjectivesFromToDoListStringArray: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>When the game's Duty List addon (_ToDoList) is visible, read its text nodes to get objective strings. Tries ObjectiveTimerTextNodes first (objective lines), then tree-walk, DutyFinderTextNodes, and flat UldManager.</summary>
|
|
private static unsafe void TryFillObjectivesFromToDoListAddon(List<DutyListEntry> result)
|
|
{
|
|
if (result.Count == 0) return;
|
|
try
|
|
{
|
|
// Try multiple addon instances (HUD can create different indices)
|
|
var addonHandle = Plugin.GameGui.GetAddonByName("_ToDoList", 1);
|
|
if (addonHandle.Address == nint.Zero) addonHandle = Plugin.GameGui.GetAddonByName("_ToDoList", 0);
|
|
if (addonHandle.Address == nint.Zero) addonHandle = Plugin.GameGui.GetAddonByName("_ToDoList", 2);
|
|
if (addonHandle.Address == nint.Zero)
|
|
{
|
|
LogDutyListAddonDebug(result.Count, found: false, ready: false, 0, 0, 0, null);
|
|
return;
|
|
}
|
|
var addon = (AddonToDoList*)addonHandle.Address;
|
|
if (addon == null || !addon->IsReady)
|
|
{
|
|
LogDutyListAddonDebug(result.Count, found: true, ready: addon != null && addon->IsReady, 0, 0, 0, null);
|
|
return;
|
|
}
|
|
|
|
int nodeListCount = addon->UldManager.NodeListCount;
|
|
int objTimerCount = addon->ObjectiveTimerTextNodes.Count;
|
|
int textNodeCount = 0;
|
|
int textNodeNonEmptyCount = 0;
|
|
|
|
var strings = new List<string>();
|
|
|
|
// 0) ObjectiveTimerTextNodes: game stores one text node per objective line; order matches duty list
|
|
try
|
|
{
|
|
var objVec = addon->ObjectiveTimerTextNodes;
|
|
if (objVec.Count > 0)
|
|
{
|
|
var span = objVec.AsSpan();
|
|
for (int i = 0; i < span.Length; i++)
|
|
{
|
|
ref readonly var data = ref span[i];
|
|
if (data.Node == null) continue;
|
|
try
|
|
{
|
|
string s = data.Node->NodeText.ToString();
|
|
if (!string.IsNullOrWhiteSpace(s)) strings.Add(s.Trim());
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
if (strings.Count >= result.Count)
|
|
{
|
|
for (int i = 0; i < result.Count && i < strings.Count; i++)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(result[i].ObjectiveText))
|
|
result[i] = result[i] with { ObjectiveText = strings[i] };
|
|
}
|
|
LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, strings.Count, strings);
|
|
return;
|
|
}
|
|
strings.Clear();
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
|
|
// 0b) ListPanel: duty list rows; each entry's node tree has text (typically name then objective per row)
|
|
if (strings.Count < result.Count * 2)
|
|
{
|
|
try
|
|
{
|
|
var listPanel = addon->ListPanel;
|
|
var entries = listPanel.Entries;
|
|
if (entries.Count > 0)
|
|
{
|
|
var rowStrings = new List<List<string>>();
|
|
var span = entries.AsSpan();
|
|
for (int r = 0; r < span.Length; r++)
|
|
{
|
|
var row = new List<string>();
|
|
if (span[r].Node != null)
|
|
CollectTextFromNode(span[r].Node, row);
|
|
rowStrings.Add(row);
|
|
}
|
|
var flat = new List<string>();
|
|
foreach (var row in rowStrings)
|
|
{
|
|
foreach (var s in row)
|
|
if (!string.IsNullOrWhiteSpace(s)) flat.Add(s.Trim());
|
|
}
|
|
if (flat.Count >= result.Count * 2)
|
|
{
|
|
for (int i = 0; i < result.Count; i++)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(result[i].ObjectiveText)) continue;
|
|
if ((i * 2 + 1) < flat.Count)
|
|
result[i] = result[i] with { ObjectiveText = flat[i * 2 + 1] };
|
|
}
|
|
LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, flat.Count, flat);
|
|
return;
|
|
}
|
|
if (flat.Count >= result.Count)
|
|
{
|
|
for (int i = 0; i < result.Count && i < flat.Count; i++)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(result[i].ObjectiveText))
|
|
result[i] = result[i] with { ObjectiveText = flat[i] };
|
|
}
|
|
LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, flat.Count, flat);
|
|
return;
|
|
}
|
|
strings = flat;
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
|
|
// 1) Tree-walk (display order: typically name then objective per quest)
|
|
if (addon->RootNode != null)
|
|
CollectTextFromNode(addon->RootNode, strings);
|
|
|
|
// 2) Try DutyFinderTextNodes if tree-walk gave too few
|
|
if (strings.Count < 2)
|
|
{
|
|
try
|
|
{
|
|
var vec = addon->DutyFinderTextNodes;
|
|
if (vec.First != null && vec.Count > 0)
|
|
{
|
|
var ptrs = (AtkTextNode**)vec.First;
|
|
for (int i = 0; i < vec.Count; i++)
|
|
{
|
|
var textNode = ptrs[i];
|
|
if (textNode == null) continue;
|
|
try
|
|
{
|
|
string s = textNode->NodeText.ToString();
|
|
if (!string.IsNullOrWhiteSpace(s)) strings.Add(s.Trim());
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
|
|
// 3) Fallback: flat UldManager node list
|
|
if (strings.Count < 2)
|
|
{
|
|
strings.Clear();
|
|
var uld = addon->UldManager;
|
|
if (uld.NodeListCount > 0)
|
|
{
|
|
for (uint i = 0; i < uld.NodeListCount; i++)
|
|
{
|
|
AtkResNode* node = uld.NodeList[i];
|
|
if (node == null || node->Type != NodeType.Text) continue;
|
|
textNodeCount++;
|
|
var textNode = node->GetAsAtkTextNode();
|
|
if (textNode == null) continue;
|
|
try
|
|
{
|
|
string s = textNode->NodeText.ToString();
|
|
if (!string.IsNullOrWhiteSpace(s))
|
|
{
|
|
textNodeNonEmptyCount++;
|
|
strings.Add(s.Trim());
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
}
|
|
if (strings.Count < 2 && addon->RootNode != null)
|
|
CollectTextFromNode(addon->RootNode, strings);
|
|
}
|
|
// Pair by index when layout is exactly [name0, obj0, name1, obj1, ...]
|
|
bool pairedByIndex = strings.Count >= result.Count * 2;
|
|
for (int i = 0; i < result.Count; i++)
|
|
{
|
|
var entry = result[i];
|
|
if (!string.IsNullOrWhiteSpace(entry.ObjectiveText)) continue;
|
|
string? obj = null;
|
|
if (pairedByIndex && (i * 2 + 1) < strings.Count)
|
|
obj = strings[i * 2 + 1];
|
|
if (string.IsNullOrWhiteSpace(obj))
|
|
{
|
|
int idx = strings.IndexOf(entry.QuestName);
|
|
if (idx < 0)
|
|
idx = strings.FindIndex(s => s.Contains(entry.QuestName, StringComparison.OrdinalIgnoreCase));
|
|
if (idx >= 0 && idx + 1 < strings.Count)
|
|
obj = strings[idx + 1];
|
|
}
|
|
if (string.IsNullOrWhiteSpace(obj)) continue;
|
|
result[i] = entry with { ObjectiveText = obj };
|
|
}
|
|
|
|
LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, strings.Count, strings, textNodeCount, textNodeNonEmptyCount);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Plugin.Logger.Verbose($"[DutyListScenario] TryFillObjectivesFromToDoListAddon: {ex.GetType().Name}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static DateTime _lastDutyListDebugLog = DateTime.MinValue;
|
|
private static void LogDutyListAddonDebug(int entryCount, bool found, bool ready, int nodeListCount, int objTimerCount, int stringCount, List<string>? stringsForSample, int textNodeCount = -1, int textNodeNonEmptyCount = -1)
|
|
{
|
|
if (entryCount == 0) return;
|
|
var now = DateTime.UtcNow;
|
|
if ((now - _lastDutyListDebugLog).TotalSeconds < 15) return;
|
|
_lastDutyListDebugLog = now;
|
|
string sample = stringsForSample != null && stringsForSample.Count > 0
|
|
? string.Join(" | ", stringsForSample.Take(2).Select(s => s.Length > 40 ? s.Substring(0, 40) + "…" : s))
|
|
: "";
|
|
string extra = (textNodeCount >= 0 || textNodeNonEmptyCount >= 0) ? $" textNodes={textNodeCount} nonEmpty={textNodeNonEmptyCount}" : "";
|
|
Plugin.Logger.Verbose($"[DutyList] addon found={found} ready={ready} nodeListCount={nodeListCount} objTimerCount={objTimerCount} stringCount={stringCount}{extra} sample=[{sample}]");
|
|
}
|
|
|
|
private static unsafe void CollectTextFromNode(AtkResNode* node, List<string> outStrings)
|
|
{
|
|
if (node == null) return;
|
|
if (node->Type == NodeType.Text)
|
|
{
|
|
var textNode = node->GetAsAtkTextNode();
|
|
if (textNode != null)
|
|
{
|
|
try
|
|
{
|
|
string s = textNode->NodeText.ToString();
|
|
if (!string.IsNullOrWhiteSpace(s)) outStrings.Add(s.Trim());
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
}
|
|
else if (node->Type == NodeType.Component)
|
|
{
|
|
var compNode = node->GetAsAtkComponentNode();
|
|
if (compNode != null && compNode->Component != null)
|
|
{
|
|
var comp = compNode->Component;
|
|
var uld = &comp->UldManager;
|
|
if (uld->RootNode != null)
|
|
CollectTextFromNode(uld->RootNode, outStrings);
|
|
// Also walk flat NodeList in case text nodes aren't under RootNode
|
|
if (uld->NodeListCount > 0 && uld->NodeList != null)
|
|
{
|
|
for (ushort i = 0; i < uld->NodeListCount; i++)
|
|
{
|
|
AtkResNode* n = uld->NodeList[i];
|
|
if (n != null && n->Type == NodeType.Text)
|
|
{
|
|
var tn = n->GetAsAtkTextNode();
|
|
if (tn != null)
|
|
{
|
|
try
|
|
{
|
|
string s = tn->NodeText.ToString();
|
|
if (!string.IsNullOrWhiteSpace(s)) outStrings.Add(s.Trim());
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (var child = node->ChildNode; child != null; child = child->NextSiblingNode)
|
|
CollectTextFromNode(child, outStrings);
|
|
}
|
|
|
|
/// <summary>Opens the Quest Journal to the given quest. Uses raw quest ID (16-bit) as expected by the game.</summary>
|
|
public static unsafe void OpenJournalForQuest(ushort questId)
|
|
{
|
|
try
|
|
{
|
|
AgentQuestJournal.Instance()->OpenForQuest((uint)questId, 1);
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
|
|
/// <summary>Gets territory ID for a map (for SetFlagMapMarker). Returns 0 if not found.</summary>
|
|
private static uint GetTerritoryIdForMap(uint mapId)
|
|
{
|
|
try
|
|
{
|
|
var mapSheet = Plugin.DataManager.GetExcelSheet<Lumina.Excel.Sheets.Map>();
|
|
if (mapSheet == null || !mapSheet.HasRow(mapId)) return 0;
|
|
var mapRow = mapSheet.GetRow(mapId);
|
|
if (mapRow.TerritoryType.RowId == 0) return 0;
|
|
return mapRow.TerritoryType.RowId;
|
|
}
|
|
catch { return 0; }
|
|
}
|
|
|
|
/// <summary>Opens the Area Map centered on the quest's current objective location. Sets a flag marker when coordinates are available so the map centers on the objective.</summary>
|
|
public static unsafe void OpenMapForQuestObjective(ushort questId, byte sequence)
|
|
{
|
|
try
|
|
{
|
|
var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
|
|
if (questSheet == null) return;
|
|
uint rowId = questId + 65536u;
|
|
if (!questSheet.HasRow(rowId)) return;
|
|
var questData = questSheet.GetRow(rowId);
|
|
|
|
var todoParam = questData.TodoParams.FirstOrDefault(p => p.ToDoCompleteSeq == sequence);
|
|
var location = todoParam.ToDoLocation.FirstOrDefault(loc => loc is not { RowId: 0, ValueNullable: null });
|
|
if (location.ValueNullable == null) return;
|
|
|
|
uint mapId = location.Value.Map.RowId;
|
|
if (mapId == 0) return;
|
|
uint territoryId = GetTerritoryIdForMap(mapId);
|
|
if (territoryId == 0) return;
|
|
|
|
var agent = AgentMap.Instance();
|
|
(float x, float y)? coords = TryGetMapCoordinates(location.Value);
|
|
if (coords.HasValue)
|
|
{
|
|
agent->SetFlagMapMarker(territoryId, mapId, coords.Value.x, coords.Value.y);
|
|
agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.FlagMarker);
|
|
}
|
|
else
|
|
{
|
|
agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.Centered);
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
|
|
/// <summary>Tries to get map X,Y from a Level/location row (Lumina column names may vary).</summary>
|
|
private static (float x, float y)? TryGetMapCoordinates(object? locationRow)
|
|
{
|
|
if (locationRow == null) return null;
|
|
try
|
|
{
|
|
var type = locationRow.GetType();
|
|
var xProp = type.GetProperty("X") ?? type.GetProperty("MapX");
|
|
var yProp = type.GetProperty("Y") ?? type.GetProperty("MapY");
|
|
if (xProp == null || yProp == null) return null;
|
|
var xVal = xProp.GetValue(locationRow);
|
|
var yVal = yProp.GetValue(locationRow);
|
|
if (xVal == null || yVal == null) return null;
|
|
float x = Convert.ToSingle(xVal);
|
|
float y = Convert.ToSingle(yVal);
|
|
return (x, y);
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>Opens the Area Map to the quest's current objective (for scenario guide hints). Prefers current objective so MSQ and Job hints each open the correct map on first click; falls back to issuer location if quest is not in the duty list.</summary>
|
|
public static unsafe void OpenMapForQuestIssuer(uint questRowId)
|
|
{
|
|
try
|
|
{
|
|
var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
|
|
if (questSheet == null || !questSheet.HasRow(questRowId)) return;
|
|
ushort questId = (ushort)(questRowId & 0xFFFF);
|
|
|
|
// Prefer current objective so clicking Job hint opens Job's map (not MSQ's) on first click
|
|
var span = QuestManager.Instance()->NormalQuests;
|
|
for (int i = 0; i < span.Length; i++)
|
|
{
|
|
if (span[i].QuestId != questId) continue;
|
|
OpenMapForQuestObjective(questId, span[i].Sequence);
|
|
return;
|
|
}
|
|
|
|
// Fallback: open to issuer location when quest is not in the duty list yet
|
|
var questData = questSheet.GetRow(questRowId);
|
|
if (questData.IssuerLocation.IsValid && questData.IssuerLocation.RowId != 0)
|
|
{
|
|
var loc = questData.IssuerLocation.Value;
|
|
uint mapId = loc.Map.RowId;
|
|
if (mapId != 0)
|
|
{
|
|
uint territoryId = GetTerritoryIdForMap(mapId);
|
|
if (territoryId != 0)
|
|
{
|
|
var agent = AgentMap.Instance();
|
|
(float x, float y)? coords = TryGetMapCoordinates(loc);
|
|
if (coords.HasValue)
|
|
{
|
|
agent->SetFlagMapMarker(territoryId, mapId, coords.Value.x, coords.Value.y);
|
|
agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.FlagMarker);
|
|
}
|
|
else
|
|
{
|
|
agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.Centered);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
|
|
/// <summary>Returns scenario guide entries (MSQ/Job path hints). Uses AgentScenarioTree when populated; otherwise derives next MSQ/Job quest from game data.</summary>
|
|
public static unsafe List<ScenarioGuideEntry> GetScenarioGuideEntries()
|
|
{
|
|
var result = new List<ScenarioGuideEntry>();
|
|
try
|
|
{
|
|
var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
|
|
if (questSheet == null) return result;
|
|
|
|
// Try agent first
|
|
var agent = AgentScenarioTree.Instance();
|
|
if (agent != null && agent->Data != null)
|
|
{
|
|
var data = agent->Data;
|
|
bool hasMsqFromAgent = false;
|
|
bool hasJobFromAgent = false;
|
|
|
|
// Main Scenario Quest - use MainScenarioQuestIds[MSQPathIndex], fallback to [0], then [3] (last completed)
|
|
byte msqPathIndex = data->MSQPathIndex;
|
|
ushort msqQuestId = msqPathIndex < 3 ? data->MainScenarioQuestIds[msqPathIndex] : data->MainScenarioQuestIds[0];
|
|
if (msqQuestId == 0) msqQuestId = data->MainScenarioQuestIds[0];
|
|
if (msqQuestId == 0) msqQuestId = data->MainScenarioQuestIds[3];
|
|
if (msqQuestId != 0)
|
|
{
|
|
uint rowId = msqQuestId + 65536u;
|
|
if (questSheet.HasRow(rowId))
|
|
{
|
|
var questData = questSheet.GetRow(rowId);
|
|
string questName = questData.Name.ExtractText();
|
|
if (string.IsNullOrWhiteSpace(questName)) questName = "???";
|
|
uint iconId = GetQuestIconId(questData);
|
|
ushort level = GetQuestLevel(questData);
|
|
result.Add(new ScenarioGuideEntry("Main Scenario", questName, iconId, level, rowId));
|
|
hasMsqFromAgent = true;
|
|
}
|
|
}
|
|
|
|
// Job quest(s) - use JobQuestIndex so we show the one the game considers selected (avoids wrong map when both slots exist)
|
|
byte jobIdx = data->JobQuestIndex;
|
|
if (jobIdx <= 1)
|
|
{
|
|
ushort questId = data->JobQuestIds[jobIdx];
|
|
if (questId != 0)
|
|
{
|
|
uint rowId = questId + 65536u;
|
|
if (questSheet.HasRow(rowId))
|
|
{
|
|
var questData = questSheet.GetRow(rowId);
|
|
string questName = questData.Name.ExtractText();
|
|
if (string.IsNullOrWhiteSpace(questName)) questName = "???";
|
|
uint iconId = GetQuestIconId(questData);
|
|
ushort level = GetQuestLevel(questData);
|
|
result.Add(new ScenarioGuideEntry("Job Quest", questName, iconId, level, rowId));
|
|
hasJobFromAgent = true;
|
|
}
|
|
}
|
|
}
|
|
if (!hasJobFromAgent)
|
|
{
|
|
for (int i = 0; i < 2; i++)
|
|
{
|
|
ushort questId = data->JobQuestIds[i];
|
|
if (questId == 0) continue;
|
|
uint rowId = questId + 65536u;
|
|
if (!questSheet.HasRow(rowId)) continue;
|
|
var questData = questSheet.GetRow(rowId);
|
|
string questName = questData.Name.ExtractText();
|
|
if (string.IsNullOrWhiteSpace(questName)) questName = "???";
|
|
uint iconId = GetQuestIconId(questData);
|
|
ushort level = GetQuestLevel(questData);
|
|
result.Add(new ScenarioGuideEntry("Job Quest", questName, iconId, level, rowId));
|
|
hasJobFromAgent = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (hasMsqFromAgent && hasJobFromAgent)
|
|
return result;
|
|
}
|
|
|
|
// Fallback: derive next MSQ and Job quest from game data when agent has no hint
|
|
TryAddNextMsqFromScenarioTree(result, questSheet);
|
|
TryAddNextJobQuestFromQuests(result, questSheet);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Plugin.Logger.Verbose($"[DutyListScenario] GetScenarioGuideEntries: {ex.Message}");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static unsafe void TryAddNextMsqFromScenarioTree(List<ScenarioGuideEntry> result, ExcelSheet<Quest> questSheet)
|
|
{
|
|
if (result.Any(e => e.Label == "Main Scenario")) return;
|
|
try
|
|
{
|
|
var scenarioSheet = Plugin.DataManager.GetExcelSheet<ScenarioTree>();
|
|
if (scenarioSheet == null) return;
|
|
|
|
var ordered = new List<(ScenarioTree St, Quest Q)>();
|
|
foreach (var st in scenarioSheet)
|
|
{
|
|
if (!questSheet.TryGetRow(st.RowId, out var quest) || !quest.Expansion.IsValid)
|
|
continue;
|
|
ordered.Add((st, quest));
|
|
}
|
|
ordered = ordered.OrderBy(x => x.St.Unknown2).ToList();
|
|
|
|
foreach (var (st, quest) in ordered)
|
|
{
|
|
if (QuestManager.IsQuestComplete(st.RowId)) continue;
|
|
string questName = quest.Name.ExtractText();
|
|
if (string.IsNullOrWhiteSpace(questName)) questName = "???";
|
|
uint iconId = GetQuestIconId(quest);
|
|
ushort level = GetQuestLevel(quest);
|
|
result.Insert(0, new ScenarioGuideEntry("Main Scenario", questName, iconId, level, quest.RowId));
|
|
return;
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
|
|
private static unsafe void TryAddNextJobQuestFromQuests(List<ScenarioGuideEntry> result, ExcelSheet<Quest> questSheet)
|
|
{
|
|
if (result.Any(e => e.Label == "Job Quest")) return;
|
|
var player = Plugin.ObjectTable?.LocalPlayer;
|
|
if (player == null) return;
|
|
uint classJobId = player.ClassJob.RowId;
|
|
try
|
|
{
|
|
foreach (var quest in questSheet)
|
|
{
|
|
if (quest.ClassJobCategory0.RowId == 0) continue;
|
|
if (quest.ClassJobCategory0.RowId != classJobId) continue;
|
|
if (!quest.JournalGenre.IsValid) continue;
|
|
if (QuestManager.IsQuestComplete(quest.RowId)) continue;
|
|
ushort reqLevel = quest.ClassJobLevel.FirstOrDefault();
|
|
if (reqLevel == 0) continue;
|
|
if (player.Level < reqLevel) continue;
|
|
|
|
string questName = quest.Name.ExtractText();
|
|
if (string.IsNullOrWhiteSpace(questName)) questName = "???";
|
|
uint iconId = GetQuestIconId(quest);
|
|
ushort level = GetQuestLevel(quest);
|
|
result.Add(new ScenarioGuideEntry("Job Quest", questName, iconId, level, quest.RowId));
|
|
return;
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
|
|
/// <summary>Returns current Duty Finder queue info when the player is in the duty queue; otherwise null. Uses the actual queued duties (from QueueInfo) and supports multiple selected duties.</summary>
|
|
public static unsafe DutyFinderQueueInfo? GetDutyFinderQueueInfo()
|
|
{
|
|
try
|
|
{
|
|
if (Conditions.Instance() == null || !Conditions.Instance()->InDutyQueue)
|
|
return null;
|
|
var finder = ContentsFinder.Instance();
|
|
if (finder == null) return null;
|
|
ref var queueInfo = ref finder->QueueInfo;
|
|
|
|
// Build duty name(s) from all queued entries (up to 5). Each entry is 8 bytes: ContentType at 0, Id at 4.
|
|
var dutyNames = new List<string>();
|
|
var queueInfoPtr = (byte*)Unsafe.AsPointer(ref queueInfo);
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
byte contentType = queueInfoPtr[i * 8 + 0];
|
|
uint id = *(uint*)(queueInfoPtr + i * 8 + 4);
|
|
if (contentType == 0) continue; // None / empty slot
|
|
if (contentType == (byte)ContentsId.ContentsType.Regular && id != 0)
|
|
{
|
|
var cfcSheet = Plugin.DataManager.GetExcelSheet<Lumina.Excel.Sheets.ContentFinderCondition>();
|
|
if (cfcSheet != null && cfcSheet.HasRow(id))
|
|
{
|
|
var row = cfcSheet.GetRow(id);
|
|
string name = row.Name.ExtractText();
|
|
if (!string.IsNullOrWhiteSpace(name)) dutyNames.Add(name);
|
|
}
|
|
}
|
|
else if (contentType == (byte)ContentsId.ContentsType.Roulette)
|
|
{
|
|
byte rouletteId = (byte)(id & 0xFF);
|
|
var rouletteSheet = Plugin.DataManager.GetExcelSheet<Lumina.Excel.Sheets.ContentRoulette>();
|
|
if (rouletteSheet != null && rouletteSheet.HasRow(rouletteId))
|
|
{
|
|
var row = rouletteSheet.GetRow(rouletteId);
|
|
string name = row.Name.ExtractText();
|
|
dutyNames.Add(string.IsNullOrWhiteSpace(name) ? "Duty Roulette" : name);
|
|
}
|
|
else
|
|
dutyNames.Add("Duty Roulette");
|
|
}
|
|
}
|
|
string dutyName = dutyNames.Count == 0 ? "Duty Finder" : (dutyNames.Count == 1 ? dutyNames[0] : string.Join(", ", dutyNames));
|
|
|
|
string statusText = queueInfo.QueueState switch
|
|
{
|
|
ContentsFinderQueueInfo.QueueStates.Pending => "Pending",
|
|
ContentsFinderQueueInfo.QueueStates.Queued => "Forming Party",
|
|
ContentsFinderQueueInfo.QueueStates.Ready => "Duty Ready",
|
|
ContentsFinderQueueInfo.QueueStates.Accepted => "Accepted",
|
|
_ => "In Queue"
|
|
};
|
|
var infoState = queueInfo.InfoState;
|
|
var elapsed = queueInfo.EnteredQueueTimestamp != 0
|
|
? TimeSpan.FromSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - queueInfo.EnteredQueueTimestamp)
|
|
: TimeSpan.Zero;
|
|
string? avgWait = null;
|
|
if (infoState.AverageWaitTime)
|
|
{
|
|
avgWait = TryGetAverageWaitFromQueueStatusMessages();
|
|
if (string.IsNullOrEmpty(avgWait)) avgWait = "—";
|
|
}
|
|
return new DutyFinderQueueInfo(
|
|
dutyName,
|
|
statusText,
|
|
infoState.TanksFound, infoState.TanksNeeded,
|
|
infoState.HealersFound, infoState.HealersNeeded,
|
|
infoState.DPSFound, infoState.DPSNeeded,
|
|
elapsed,
|
|
avgWait);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Plugin.Logger.Verbose($"[DutyListScenario] GetDutyFinderQueueInfo: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns current duty information when inside a dungeon/trial/raid (BoundByDuty and not in queue); otherwise null.</summary>
|
|
public static unsafe DutyInfo? GetDutyInfo()
|
|
{
|
|
try
|
|
{
|
|
if (!Plugin.Condition[ConditionFlag.BoundByDuty]) return null;
|
|
if (Conditions.Instance() != null && Conditions.Instance()->InDutyQueue) return null;
|
|
|
|
var stringArray = ToDoListStringArray.Instance();
|
|
var numberArray = ToDoListNumberArray.Instance();
|
|
if (stringArray == null || numberArray == null) return null;
|
|
|
|
int dutyState = numberArray->DutyState;
|
|
// DutyState 2 = in duty, 3 = in duty with single text line (per ToDoListNumberArray).
|
|
if (dutyState != 2 && dutyState != 3) return null;
|
|
|
|
byte* strBase = (byte*)stringArray;
|
|
string ReadStr(int offset)
|
|
{
|
|
byte* ptr = *(byte**)(strBase + offset);
|
|
if (ptr == null) return "";
|
|
try { return Marshal.PtrToStringUTF8((IntPtr)ptr) ?? ""; }
|
|
catch { return ""; }
|
|
}
|
|
|
|
string dutyName = ReadStr(159 * 8).Trim(); // ActiveDutyTitle
|
|
string timerText = ReadStr(162 * 8).Trim(); // DutyTimer
|
|
|
|
int objectiveCount = numberArray->DutyObjectiveCount;
|
|
if (objectiveCount <= 0 || objectiveCount > 10) objectiveCount = 0;
|
|
|
|
var objectives = new List<DutyObjectiveEntry>();
|
|
const int dutyObjectivesOffset = 165 * 8;
|
|
const int dutyTimersOffset = 175 * 8;
|
|
byte* numBase = (byte*)numberArray;
|
|
for (int i = 0; i < objectiveCount; i++)
|
|
{
|
|
string text = ReadStr(dutyObjectivesOffset + i * 8).Trim();
|
|
string progress = ReadStr(dutyTimersOffset + i * 8).Trim();
|
|
if (string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(progress)) continue;
|
|
int value = *(int*)(numBase + (147 + i) * 4); // _dutyObjectiveValue
|
|
if (string.IsNullOrWhiteSpace(progress) && value >= 0)
|
|
progress = value.ToString();
|
|
else if (value < 0 && string.IsNullOrWhiteSpace(progress))
|
|
progress = "?";
|
|
string displayProgress = FormatDutyObjectiveProgress(progress);
|
|
objectives.Add(new DutyObjectiveEntry(text, displayProgress));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(dutyName) && objectives.Count == 0 && string.IsNullOrWhiteSpace(timerText))
|
|
return null;
|
|
|
|
return new DutyInfo(
|
|
string.IsNullOrWhiteSpace(dutyName) ? "Duty" : dutyName,
|
|
timerText,
|
|
objectives);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Plugin.Logger.Verbose($"[DutyListScenario] GetDutyInfo: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Read average wait time from ToDoList string array queue status messages (slots 5-8). Prefers static format (e.g. "7m") over timer format (e.g. "7:01").</summary>
|
|
private static unsafe string? TryGetAverageWaitFromQueueStatusMessages()
|
|
{
|
|
try
|
|
{
|
|
var stringArray = ToDoListStringArray.Instance();
|
|
if (stringArray == null) return null;
|
|
byte* baseAddr = (byte*)stringArray;
|
|
const int queueStatusOffset = 5 * 8; // _queueStatusMessages
|
|
string? fallback = null; // use if we only find timer-style values
|
|
for (int i = 0; i < 4; i++)
|
|
{
|
|
byte* ptr = *(byte**)(baseAddr + queueStatusOffset + i * 8);
|
|
if (ptr == null) continue;
|
|
string s;
|
|
try { s = Marshal.PtrToStringUTF8((IntPtr)ptr) ?? ""; }
|
|
catch { continue; }
|
|
if (string.IsNullOrWhiteSpace(s)) continue;
|
|
s = s.Trim();
|
|
// Game can show "Average Wait Time: 7:01/Average Wait Time: 7m" (timer then static) or vice versa.
|
|
if (s.IndexOf("Average", StringComparison.OrdinalIgnoreCase) < 0 || s.IndexOf("Wait", StringComparison.OrdinalIgnoreCase) < 0)
|
|
{
|
|
if ((s.Contains("m") || s.Contains("s")) && s.Length > 0 && (char.IsDigit(s[0]) || s.StartsWith("Less", StringComparison.OrdinalIgnoreCase)))
|
|
return s;
|
|
continue;
|
|
}
|
|
const string label = "Average Wait Time:";
|
|
int start = 0;
|
|
while (start < s.Length)
|
|
{
|
|
int idx = s.IndexOf(label, start, StringComparison.OrdinalIgnoreCase);
|
|
if (idx < 0) break;
|
|
int valueStart = idx + label.Length;
|
|
if (valueStart >= s.Length) break;
|
|
int valueEnd = s.Length;
|
|
int nextLabel = s.IndexOf(label, valueStart, StringComparison.OrdinalIgnoreCase);
|
|
if (nextLabel >= 0) valueEnd = nextLabel;
|
|
string value = s.Substring(valueStart, valueEnd - valueStart).Trim().TrimEnd('/', ' ');
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
if (IsStaticAverageWaitFormat(value))
|
|
return value;
|
|
fallback ??= value;
|
|
}
|
|
start = valueEnd;
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Plugin.Logger.Verbose($"[DutyListScenario] TryGetAverageWaitFromQueueStatusMessages: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>True if the value looks like a static estimate ("7m", "2m 30s", "Less than 5m") rather than a timer ("7:01").</summary>
|
|
private static bool IsStaticAverageWaitFormat(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return false;
|
|
// Timer format is minutes:seconds (e.g. "7:01"); static is "7m", "7m 30s", "Less than 5m".
|
|
if (value.Contains(':')) return false;
|
|
return value.Contains("m") || value.Contains("s") || value.StartsWith("Less", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>Format duty objective progress from game format (e.g. "0/1:0") to "X out of Y step(s)" with " ✓" when completed.</summary>
|
|
private static string FormatDutyObjectiveProgress(string progress)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(progress)) return "";
|
|
string main = progress;
|
|
bool completed = false;
|
|
int colonIdx = progress.IndexOf(':');
|
|
if (colonIdx >= 0)
|
|
{
|
|
main = progress.Substring(0, colonIdx).Trim();
|
|
if (colonIdx + 1 < progress.Length)
|
|
{
|
|
string flag = progress.Substring(colonIdx + 1).Trim();
|
|
completed = flag == "1";
|
|
}
|
|
}
|
|
if (string.IsNullOrWhiteSpace(main)) return progress;
|
|
int slashIdx = main.IndexOf('/');
|
|
if (slashIdx < 0)
|
|
return completed ? progress + " ✓" : progress;
|
|
string currentStr = main.Substring(0, slashIdx).Trim();
|
|
string totalStr = slashIdx + 1 < main.Length ? main.Substring(slashIdx + 1).Trim() : "";
|
|
if (!int.TryParse(currentStr, out int current) || !int.TryParse(totalStr, out int total) || total <= 0)
|
|
return completed ? progress + " ✓" : progress;
|
|
string stepWord = total == 1 ? "step" : "steps";
|
|
string result = $"{current} out of {total} {stepWord}";
|
|
if (completed) result += " ✓";
|
|
return result;
|
|
}
|
|
}
|
|
}
|