Files
ConfigurableCombo/JobActionsHelper.cs
T
2026-02-04 22:12:08 -05:00

591 lines
24 KiB
C#

using System;
using System.Globalization;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Dalamud.Game;
using Dalamud.Plugin.Services;
using Lumina.Excel.Sheets;
using Action = Lumina.Excel.Sheets.Action;
namespace ConfigurableCombo;
/// <summary>Job list filter: combat (DoW/DoM) or crafting & gathering (DoH/DoL).</summary>
public enum JobListKind
{
Combat,
CraftingGathering,
}
/// <summary>
/// Fetches job list and actions by category for the config.
/// Uses dynamic to tolerate different Lumina API versions.
/// </summary>
internal static class JobActionsHelper
{
private const uint ActionCategoryItem = 2;
/// <summary>Max Action RowId to scan (avoids relying on foreach which may skip rows in some Lumina versions).</summary>
private const uint MaxActionRowId = 60000u;
/// <summary>FFXIV ClassJob RowIds: 8-18 are DoH/DoL (CRP..FSH). 1-7 and 19+ are combat.</summary>
private static bool IsPvP(dynamic r)
{
try
{
if (r.IsPvP == null) return false;
if (r.IsPvP is bool b) return b;
return Convert.ToInt32(r.IsPvP) != 0;
}
catch { return false; }
}
private static uint GetActionCategoryRow(dynamic r)
{
try
{
dynamic cat;
try { cat = r.ActionCategory; } catch { return 0; }
try { return (uint)Convert.ToUInt32(cat.RowId); } catch { }
try { return (uint)Convert.ToUInt32(cat.Row); } catch { }
try { dynamic v = cat.Value; if (v != null) return (uint)Convert.ToUInt32(v.RowId); } catch { }
try { dynamic v = cat.Value; if (v != null) return (uint)Convert.ToUInt32(v.Row); } catch { }
try { dynamic v = cat.ValueNullable; if (v != null) return (uint)Convert.ToUInt32(v.RowId); } catch { }
}
catch { }
return 0;
}
private static uint GetRowId(dynamic r)
{
try { return (uint)(r.RowId ?? r.Key ?? 0); } catch { return 0; }
}
/// <summary>True if the name is a real display name (not #rowId fallback or _rsv_ placeholder).</summary>
private static bool HasProperName(string name, uint rowId)
{
if (string.IsNullOrWhiteSpace(name)) return false;
if (name.Trim() == $"#{rowId}") return false;
if (name.Contains("_rsv_", StringComparison.OrdinalIgnoreCase)) return false;
return true;
}
/// <summary>True if the name uses only English-friendly characters (ASCII printable + Latin-1 supplement). Filters out CJK and other scripts.</summary>
private static bool IsLikelyEnglish(string name)
{
if (string.IsNullOrWhiteSpace(name)) return false;
foreach (char c in name)
{
if (c >= 0x20 && c <= 0x7E) continue; // ASCII printable
if (c >= 0xA0 && c <= 0xFF) continue; // Latin-1 supplement (é, ñ, ü, etc.)
return false;
}
return true;
}
private static string GetActionName(dynamic r, uint rowId)
{
try
{
// Support both reference-type Name and value-type (e.g. ReadOnlySeString)
object? nameObj = null;
try { nameObj = r.Name; } catch { }
if (nameObj != null)
{
var n = nameObj.ToString();
if (!string.IsNullOrWhiteSpace(n)) return n.Trim();
}
try { nameObj = r.Singular; } catch { }
if (nameObj != null)
{
var n = nameObj.ToString();
if (!string.IsNullOrWhiteSpace(n)) return n.Trim();
}
}
catch { }
return $"#{rowId}";
}
private static uint GetIconId(dynamic r)
{
try { return (uint)(r.Icon ?? 0); } catch { return 0; }
}
private const uint CraftingGatheringMin = 8;
private const uint CraftingGatheringMax = 18;
private static readonly Dictionary<uint, List<(uint ActionId, string Name, uint IconId)>> _jobActionsCache = new();
private static List<(uint JobId, string Name)>? _combatJobListCache;
private static List<(uint JobId, string Name)>? _craftingGatheringJobListCache;
private static readonly Dictionary<uint, string> _jobNameByRowId = new();
private static readonly Dictionary<uint, List<(uint ActionId, string Name, uint IconId)>> _pvpActionsCacheByJob = new();
private static readonly Dictionary<uint, List<(uint ActionId, string Name, uint IconId)>> _roleActionsCacheByJob = new();
private static List<(uint ActionId, string Name, uint IconId)>? _consumableActionsCache;
/// <summary>Jobs for the dropdown. Combat = DoW/DoM (RowId 1-7, 19+). CraftingGathering = DoH/DoL (RowId 8-18). Includes Unspecified. Cached per kind.</summary>
public static List<(uint JobId, string Name)> GetJobList(JobListKind kind)
{
if (kind == JobListKind.Combat && _combatJobListCache != null)
return _combatJobListCache;
if (kind == JobListKind.CraftingGathering && _craftingGatheringJobListCache != null)
return _craftingGatheringJobListCache;
var result = new List<(uint JobId, string Name)> { (0, "Unspecified") };
try
{
var sheet = Service.DataManager.GetExcelSheet<Lumina.Excel.Sheets.ClassJob>(ClientLanguage.English);
if (sheet != null)
{
foreach (var row in sheet)
{
try
{
dynamic r = row;
uint rowId = (uint)r.RowId;
if (rowId == 0) continue;
if (kind == JobListKind.Combat && (rowId == 43 || rowId == 44 || rowId == 45)) continue;
bool isCraftGather = rowId >= CraftingGatheringMin && rowId <= CraftingGatheringMax;
if (kind == JobListKind.Combat && isCraftGather) continue;
if (kind == JobListKind.CraftingGathering && !isCraftGather) continue;
string name = r.Name?.ToString() ?? r.NameEnglish?.ToString() ?? r.Abbreviation?.ToString() ?? $"#{rowId}";
if (string.IsNullOrWhiteSpace(name)) name = $"#{rowId}";
name = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(name.Trim().ToLowerInvariant());
if (!HasProperName(name, rowId) || !IsLikelyEnglish(name)) continue;
result.Add((rowId, name));
_jobNameByRowId[rowId] = name;
}
catch { }
}
result = result.OrderBy(x => x.Name).ToList();
result.RemoveAll(x => x.JobId == 0);
result.Insert(0, (0, "Unspecified"));
}
}
catch { }
if (kind == JobListKind.Combat)
_combatJobListCache = result;
else
_craftingGatheringJobListCache = result;
return result;
}
/// <summary>Job-related PvE actions for the given job. Cached per job.</summary>
public static List<(uint ActionId, string Name, uint IconId)> GetActionsForJob(uint jobId)
{
if (jobId == 0) return new List<(uint, string, uint)>();
lock (_jobActionsCache)
{
if (_jobActionsCache.TryGetValue(jobId, out var cached))
return cached;
}
var list = BuildJobActions(jobId);
lock (_jobActionsCache)
{
_jobActionsCache[jobId] = list;
}
return list;
}
/// <summary>PvP actions for the given job (or all PvP if jobId 0). Cached per job.</summary>
public static List<(uint ActionId, string Name, uint IconId)> GetPvPActions(uint jobId)
{
lock (_pvpActionsCacheByJob)
{
if (_pvpActionsCacheByJob.TryGetValue(jobId, out var cached))
return cached;
}
var list = BuildPvPActions(jobId);
lock (_pvpActionsCacheByJob)
{
_pvpActionsCacheByJob[jobId] = list;
}
return list;
}
/// <summary>Role actions for the given job's category (shared actions like Second Wind, Leg Sweep). Cached per job.</summary>
public static List<(uint ActionId, string Name, uint IconId)> GetRoleActions(uint jobId)
{
lock (_roleActionsCacheByJob)
{
if (_roleActionsCacheByJob.TryGetValue(jobId, out var cached))
return cached;
}
var list = BuildRoleActions(jobId);
lock (_roleActionsCacheByJob)
{
_roleActionsCacheByJob[jobId] = list;
}
return list;
}
/// <summary>Consumable items (ActionCategory Item). Cached.</summary>
public static List<(uint ActionId, string Name, uint IconId)> GetConsumableActions()
{
if (_consumableActionsCache != null)
return _consumableActionsCache;
_consumableActionsCache = BuildConsumableActions();
return _consumableActionsCache;
}
private static List<(uint ActionId, string Name, uint IconId)> BuildPvPActions(uint jobId)
{
var result = new List<(uint, string, uint)>();
try
{
var sheet = Service.DataManager.GetExcelSheet<Action>(ClientLanguage.English);
if (sheet == null)
{
Service.PluginLog.Warning("[ConfigurableCombo] Action sheet is null (PvP).");
return result;
}
byte jobByte = jobId == 0 ? (byte)0 : (byte)Math.Clamp(jobId, 0, 255);
foreach (var row in sheet)
{
try
{
dynamic r = row;
if (!IsPvP(r)) continue;
uint rowId = GetRowId(r);
if (rowId == 0) continue;
if (jobId != 0 && !IsActionForJob(r, jobId, jobByte))
continue;
var name = GetActionName(r, rowId);
if (!HasProperName(name, rowId) || !IsLikelyEnglish(name)) continue;
result.Add((rowId, name, GetIconId(r)));
}
catch { }
}
result = result.OrderBy(x => x.Item2).ToList();
if (result.Count == 0)
Service.PluginLog.Debug("[ConfigurableCombo] BuildPvPActions: 0 actions for jobId={JobId}.", jobId);
}
catch (Exception ex) { Service.PluginLog.Warning(ex, "[ConfigurableCombo] BuildPvPActions failed."); }
return result;
}
/// <summary>Known PvE role action RowIds (from game data / WrathCombo). Displayed regardless of selected job.</summary>
private static readonly uint[] KnownRoleActionIds =
{
197, 198, 200, 201, 203, 204, 206, 207,
7531, 7533, 7535, 7537, 7538, 7540,
7541, 7542, 7546, 7548, 7549,
7551, 7553, 7554, 7557,
7559, 7560, 7561, 7562, 7568, 7571,
7863,
16560, 25880,
4238, 4239,
};
/// <summary>Role actions: built from known role action IDs so they always display (Tank, Healer, Melee, PhysRanged, Caster, Magic).</summary>
private static List<(uint ActionId, string Name, uint IconId)> BuildRoleActions(uint jobId)
{
var result = new List<(uint, string, uint)>();
try
{
var sheet = Service.DataManager.GetExcelSheet<Action>(ClientLanguage.English);
if (sheet == null)
{
Service.PluginLog.Warning("[ConfigurableCombo] Action sheet is null (Role Actions).");
return result;
}
foreach (uint id in KnownRoleActionIds)
{
try
{
if (!sheet.TryGetRow(id, out var row)) continue;
dynamic r = row;
if (IsPvP(r)) continue;
var name = GetActionName(r, id);
if (!HasProperName(name, id) || !IsLikelyEnglish(name)) continue;
result.Add((id, name, GetIconId(r)));
}
catch { }
}
result = result.OrderBy(x => x.Item2).ToList();
}
catch (Exception ex) { Service.PluginLog.Warning(ex, "[ConfigurableCombo] BuildRoleActions failed."); }
return result;
}
/// <summary>Get action's ClassJobCategory RowId via reflection (same pattern as GetJobCategoryRowId so Role Actions work).</summary>
private static uint GetActionClassJobCategoryRowId(object? actionRow)
{
if (actionRow == null) return 0;
var cat = GetRowRefPropertyValue(actionRow, "ClassJobCategory");
return GetRowRefRowId(cat);
}
/// <summary>Get a RowRef-like property value from a row object, trying exact and case-insensitive property names.</summary>
private static object? GetRowRefPropertyValue(object? row, string propertyName)
{
if (row == null) return null;
try
{
var type = row.GetType();
var p = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
if (p != null) return p.GetValue(row);
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase))
return prop.GetValue(row);
}
}
catch { }
return null;
}
/// <summary>Consumables: usable items from the Item sheet (ItemAction set). IDs are Item RowIds; game uses these for item hotbar slots.</summary>
private static List<(uint ActionId, string Name, uint IconId)> BuildConsumableActions()
{
var result = new List<(uint, string, uint)>();
try
{
// Build from Item sheet: items with a use action (ItemAction valid) are consumables
var itemSheet = Service.DataManager.GetExcelSheet<Lumina.Excel.Sheets.Item>(ClientLanguage.English);
if (itemSheet == null)
{
Service.PluginLog.Warning("[ConfigurableCombo] Item sheet is null (Consumable).");
return result;
}
foreach (var row in itemSheet)
{
try
{
dynamic r = row;
uint rowId = GetRowId(r);
if (rowId == 0) continue;
if (!ItemHasUseAction(r)) continue;
string name = GetItemName(r, rowId);
if (!HasProperName(name, rowId) || !IsLikelyEnglish(name)) continue;
result.Add((rowId, name, GetIconId(r)));
}
catch { }
}
result = result.OrderBy(x => x.Item2).ToList();
if (result.Count == 0)
Service.PluginLog.Debug("[ConfigurableCombo] BuildConsumableActions: 0 usable items found.");
}
catch (Exception ex) { Service.PluginLog.Warning(ex, "[ConfigurableCombo] BuildConsumableActions failed."); }
return result;
}
private static bool ItemHasUseAction(dynamic r)
{
try
{
dynamic ia;
try { ia = r.ItemAction; } catch { return false; }
try { if (Convert.ToUInt32(ia.RowId) != 0) return true; } catch { }
try { if (Convert.ToUInt32(ia.Row) != 0) return true; } catch { }
try { if (ia.IsValid == true) return true; } catch { }
}
catch { }
return false;
}
private static string GetItemName(dynamic r, uint rowId)
{
try
{
object? nameObj = null;
try { nameObj = r.Name; } catch { }
if (nameObj != null)
{
var n = nameObj.ToString();
if (!string.IsNullOrWhiteSpace(n)) return n.Trim();
}
try { nameObj = r.Singular; } catch { }
if (nameObj != null)
{
var n = nameObj.ToString();
if (!string.IsNullOrWhiteSpace(n)) return n.Trim();
}
}
catch { }
return $"#{rowId}";
}
/// <summary>True if the action belongs to the given job (ClassJob match, parent class match, or ClassJobCategory). Uses jobCategoryRowId as fallback when IsJobInCategory is unavailable.</summary>
private static bool IsActionForJob(dynamic r, uint jobId, byte jobByte, uint? jobCategoryRowId = null, uint? parentClassJobId = null)
{
try
{
uint actionJob = GetClassJobRowId(r);
if (actionJob == jobId) return true;
if (parentClassJobId != null && parentClassJobId.Value != 0 && actionJob == parentClassJobId.Value) return true;
dynamic? cat = null;
try { cat = r.ClassJobCategory; } catch { }
if (cat == null) return false;
uint catRowId = 0;
try { catRowId = (uint)Convert.ToUInt32(cat.RowId); } catch { }
try { if (catRowId == 0) catRowId = (uint)Convert.ToUInt32(cat.Row); } catch { }
if (catRowId == 1) return false;
dynamic? val = null;
try { val = cat.Value; } catch { }
try { if (val == null) val = cat.ValueNullable; } catch { }
if (val != null)
{
try { return (bool)val.IsJobInCategory(jobByte); } catch { }
try { return (bool)val.IsJobInCategory((byte)jobId); } catch { }
}
// Fallback: include if action's category matches the job's category (role actions)
if (jobCategoryRowId != null && jobCategoryRowId.Value != 0 && catRowId == jobCategoryRowId.Value)
return true;
}
catch { }
return false;
}
/// <summary>Gets the ClassJobCategory RowId for the given job from the ClassJob sheet (for role-action fallback).</summary>
private static uint GetJobCategoryRowId(uint jobId)
{
if (jobId == 0) return 0;
try
{
var sheet = Service.DataManager.GetExcelSheet<Lumina.Excel.Sheets.ClassJob>(ClientLanguage.English);
if (sheet == null) return 0;
if (!sheet.TryGetRow(jobId, out var row)) return 0;
var cat = GetRowRefPropertyValue((object)row, "ClassJobCategory");
return GetRowRefRowId(cat);
}
catch { }
return 0;
}
/// <summary>Gets the parent class RowId for a job (e.g. NIN 30 -> ROG 29). Actions like Spinning Edge are tied to the class, not the job.</summary>
private static uint GetParentClassJobId(uint jobId)
{
if (jobId == 0) return 0;
try
{
var sheet = Service.DataManager.GetExcelSheet<Lumina.Excel.Sheets.ClassJob>(ClientLanguage.English);
if (sheet == null) return 0;
if (!sheet.TryGetRow(jobId, out var row)) return 0;
object boxed = row;
var type = boxed.GetType();
var parentProp = type.GetProperty("ClassJobParent", BindingFlags.Public | BindingFlags.Instance);
if (parentProp == null) return 0;
var parent = parentProp.GetValue(boxed);
if (parent == null) return 0;
try { return (uint)Convert.ToUInt32(((dynamic)parent).RowId); } catch { }
try { return (uint)Convert.ToUInt32(((dynamic)parent).Row); } catch { }
return GetRowRefRowId(parent);
}
catch { }
return 0;
}
/// <summary>Get a uint row id from any RowRef-like object (Lumina 5 struct) via reflection when dynamic fails.</summary>
private static uint GetRowRefRowId(object? rowRef)
{
if (rowRef == null) return 0;
try
{
var t = rowRef.GetType();
foreach (var name in new[] { "RowId", "Row", "Key" })
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
if (p == null) continue;
var v = p.GetValue(rowRef);
if (v == null) continue;
if (v is uint u) return u;
if (v is int i && i >= 0) return (uint)i;
try { return Convert.ToUInt32(v); } catch { }
}
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (f.FieldType != typeof(uint) && f.FieldType != typeof(int)) continue;
var v = f.GetValue(rowRef);
if (v == null) continue;
try { var u = Convert.ToUInt32(v); if (u != 0) return u; } catch { }
}
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (p.PropertyType != typeof(uint) && p.PropertyType != typeof(int)) continue;
try
{
var v = p.GetValue(rowRef);
if (v == null) continue;
var u = Convert.ToUInt32(v);
if (u != 0) return u;
}
catch { }
}
}
catch { }
return 0;
}
private static uint GetClassJobRowId(dynamic r)
{
try
{
dynamic cj;
try { cj = r.ClassJob; } catch { return 0; }
try { return (uint)Convert.ToUInt32(cj.RowId); } catch { }
try { return (uint)Convert.ToUInt32(cj.Row); } catch { }
try { return (uint)Convert.ToUInt32(cj.Key); } catch { }
try { dynamic v = cj.Value; if (v != null) return (uint)Convert.ToUInt32(v.RowId); } catch { }
try { dynamic v = cj.Value; if (v != null) return (uint)Convert.ToUInt32(v.Row); } catch { }
try { dynamic v = cj.ValueNullable; if (v != null) return (uint)Convert.ToUInt32(v.RowId); } catch { }
return GetRowRefRowId((object?)cj);
}
catch { }
return 0;
}
private static List<(uint ActionId, string Name, uint IconId)> BuildJobActions(uint jobId)
{
var result = new List<(uint, string, uint)>();
try
{
var sheet = Service.DataManager.GetExcelSheet<Action>(ClientLanguage.English);
if (sheet == null)
{
Service.PluginLog.Warning("[ConfigurableCombo] Action sheet is null (Job).");
return result;
}
byte jobByte = (byte)Math.Clamp(jobId, 0, 255);
uint jobCategoryRowId = GetJobCategoryRowId(jobId);
uint parentClassJobId = GetParentClassJobId(jobId);
for (uint id = 1; id <= MaxActionRowId; id++)
{
try
{
if (!sheet.TryGetRow(id, out var row)) continue;
dynamic r = row;
if (IsPvP(r)) continue;
int classJobLevel = 0;
try { classJobLevel = (int)(r.ClassJobLevel ?? 0); } catch { }
bool inCategory = IsActionForJob(r, jobId, jobByte, jobCategoryRowId, parentClassJobId);
if (classJobLevel <= 0 && !inCategory) continue;
if (!inCategory) continue;
var name = GetActionName(r, id);
if (!HasProperName(name, id) || !IsLikelyEnglish(name)) continue;
result.Add((id, name, GetIconId(r)));
}
catch { }
}
result = result.OrderBy(x => x.Item2).ToList();
if (result.Count == 0)
Service.PluginLog.Debug("[ConfigurableCombo] BuildJobActions: 0 actions for jobId={JobId}. Select a job and ensure game data is loaded.", jobId);
}
catch (Exception ex) { Service.PluginLog.Warning(ex, "[ConfigurableCombo] BuildJobActions failed."); }
return result;
}
public static string GetJobName(uint jobId)
{
if (jobId == 0) return "Unspecified";
if (_jobNameByRowId.TryGetValue(jobId, out var name) && !string.IsNullOrEmpty(name))
return name;
GetJobList(JobListKind.Combat);
GetJobList(JobListKind.CraftingGathering);
return _jobNameByRowId.TryGetValue(jobId, out name) ? name : $"#{jobId}";
}
}