Initial commit: MSQ Progress plugin v1.0.0

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-21 18:57:38 -05:00
commit 919b72051f
10 changed files with 831 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
using Dalamud.Plugin.Services;
using Lumina.Excel.Sheets;
namespace MSQProgress;
/// <summary>
/// Maps ContentFinderCondition RowId to the Quest RowId that unlocks that duty.
/// Loaded from embedded DutyUnlockData.json (fallback) and then filled from the game's ContentFinderCondition sheet (UnlockQuest column) when DataManager is available, so all duties are covered.
/// </summary>
public static class DutyUnlockMap
{
private static Dictionary<uint, uint>? _cfcToQuest;
private static bool _filledFromGame;
private static readonly object Lock = new();
public static bool TryGetUnlockQuest(uint contentFinderConditionRowId, out uint questRowId, IDataManager? dataManager = null)
{
EnsureLoaded(dataManager);
if (_cfcToQuest != null && _cfcToQuest.TryGetValue(contentFinderConditionRowId, out var q) && q != 0)
{
questRowId = q;
return true;
}
questRowId = 0;
return false;
}
private static void EnsureLoaded(IDataManager? dataManager = null)
{
lock (Lock)
{
if (_cfcToQuest == null)
{
_cfcToQuest = new Dictionary<uint, uint>();
try
{
var asm = Assembly.GetExecutingAssembly();
var name = asm.GetName().Name + ".DutyUnlockData.json";
using var stream = asm.GetManifestResourceStream(name);
if (stream != null)
{
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var entries = JsonSerializer.Deserialize<List<CfcQuestEntry>>(json);
if (entries != null)
{
foreach (var e in entries)
{
if (e.QuestRowId != 0)
_cfcToQuest[e.CfcRowId] = e.QuestRowId;
}
}
}
}
catch
{
// ignore
}
}
if (!_filledFromGame && dataManager != null && _cfcToQuest != null)
{
_filledFromGame = true;
try
{
var sheet = dataManager.GetExcelSheet<ContentFinderCondition>();
if (sheet != null)
{
foreach (var row in sheet)
{
if (row.RowId == 0) continue;
var q = GetUnlockQuestFromRow(row);
if (q != 0)
_cfcToQuest[row.RowId] = q;
}
}
}
catch
{
_filledFromGame = false;
}
}
}
}
/// <summary>Export current CFC→Quest map to a JSON file (e.g. after loading from game). Use to regenerate DutyUnlockData.json.</summary>
public static string? ExportToJsonFile(IDataManager dataManager, string filePath)
{
EnsureLoaded(dataManager);
if (_cfcToQuest == null || _cfcToQuest.Count == 0) return null;
try
{
var entries = new List<CfcQuestEntry>();
foreach (var kv in _cfcToQuest)
{
if (kv.Value != 0)
entries.Add(new CfcQuestEntry { cfc = kv.Key, q = kv.Value });
}
entries.Sort((a, b) => a.CfcRowId.CompareTo(b.CfcRowId));
var json = JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = false });
File.WriteAllText(filePath, json);
return filePath;
}
catch
{
return null;
}
}
internal static uint GetUnlockQuestFromRow(ContentFinderCondition row)
{
var rowType = row.GetType();
// Prefer known column names
foreach (var propName in new[] { "UnlockQuest", "UnlockCriteria" })
{
var prop = rowType.GetProperty(propName);
if (prop == null) continue;
try
{
var val = prop.GetValue(row);
if (val == null) continue;
var id = ExtractQuestRowIdFromValue(val);
if (id != 0) return id;
}
catch { /* ignore */ }
}
// Fallback: any property whose name suggests unlock/quest (Lumina may use different names)
foreach (var prop in rowType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var name = prop.Name;
if (!name.Contains("Unlock", StringComparison.OrdinalIgnoreCase) && !name.Contains("Quest", StringComparison.OrdinalIgnoreCase))
continue;
try
{
var val = prop.GetValue(row);
if (val == null) continue;
var id = ExtractQuestRowIdFromValue(val);
if (id != 0) return id;
}
catch { /* ignore */ }
}
return 0;
}
/// <summary>Try to get a Quest row ID from a value (LazyRow/RowRef/ExcelRow). Handles Lumina 5 structs.</summary>
private static uint ExtractQuestRowIdFromValue(object val)
{
var t = val.GetType();
// Row.RowId (e.g. LazyRow<Quest>.Row)
if (t.GetProperty("Row", BindingFlags.Public | BindingFlags.Instance) is PropertyInfo rowProp)
{
var inner = rowProp.GetValue(val);
if (inner != null)
{
var innerId = GetRowIdFromObject(inner);
if (innerId != 0) return innerId;
}
}
return GetRowIdFromObject(val);
}
private static uint GetRowIdFromObject(object obj)
{
if (obj == null) return 0;
var t = obj.GetType();
if (t.GetProperty("RowId", BindingFlags.Public | BindingFlags.Instance) is PropertyInfo idProp)
{
var v = idProp.GetValue(obj);
if (v is uint u && u != 0) return u;
if (v is int i && i > 0) return (uint)i;
}
var keyProp = t.GetProperty("Key", BindingFlags.Public | BindingFlags.Instance);
if (keyProp != null)
{
var v = keyProp.GetValue(obj);
if (v is uint u && u != 0) return u;
if (v is int i && i > 0) return (uint)i;
}
return 0;
}
private sealed class CfcQuestEntry
{
public uint cfc { get; set; }
public uint q { get; set; }
public uint CfcRowId => cfc;
public uint QuestRowId => q;
}
}