namespace Aeshnidae.AllegianceBounty;
/// One entry of Quests.json - a quest the allegiance is paid for.
public class QuestEntry
{
/// The wiki title; what /bounty and the announcement show.
public string Name { get; set; } = "";
/// "quest", "killtask" or "legendary" - decides the default percent.
public string Kind { get; set; } = "quest";
public int Level { get; set; }
///
/// The quest flag(s) whose stamp is the completion. Matched case-insensitively
/// against what QuestManager.Update writes. Empty means the quest cannot be detected
/// (the note says why) and it is listed for the record only.
///
public List Flags { get; set; } = new();
///
/// Percent of kill experience this quest is worth. Missing or 0 falls back to
/// Settings.QuestPercent / KillTaskPercent by kind.
///
public double? Percent { get; set; }
public string Npc { get; set; } = "";
public string Note { get; set; } = "";
public bool IsKillTask => string.Equals(Kind, "killtask", StringComparison.OrdinalIgnoreCase);
public double EffectivePercent(Settings s) =>
Percent is > 0 ? Percent.Value : IsKillTask ? s.KillTaskPercent : s.QuestPercent;
}
///
/// The quest list, loaded from Quests.json and indexed by flag for the stamp hook.
///
public static class QuestList
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static IReadOnlyList Entries { get; private set; } = Array.Empty();
/// flag (lower-case) -> entry. Read on every quest stamp; built once per load.
private static Dictionary _byFlag = new();
public static int Detectable => Entries.Count(e => e.Flags.Count > 0);
public static void Load(string modPath, string file)
{
var path = Path.Combine(modPath, file);
try
{
if (!File.Exists(path))
{
ModManager.Log($"[{Mod.Name}] quest list {path} not found - no quest can be detected until it is", ModManager.LogLevel.Warn);
Entries = Array.Empty();
_byFlag = new();
return;
}
var entries = JsonSerializer.Deserialize>(File.ReadAllText(path), JsonOptions) ?? new();
var byFlag = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var e in entries)
{
e.Flags ??= new();
foreach (var flag in e.Flags)
{
if (string.IsNullOrWhiteSpace(flag))
continue;
if (byFlag.TryGetValue(flag, out var other) && !ReferenceEquals(other, e))
ModManager.Log($"[{Mod.Name}] flag '{flag}' is listed under both '{other.Name}' and '{e.Name}'; the first wins", ModManager.LogLevel.Warn);
else
byFlag[flag] = e;
}
}
Entries = entries;
_byFlag = byFlag;
ModManager.Log($"[{Mod.Name}] {entries.Count} quests loaded from {file}, {Detectable} with a flag ({byFlag.Count} flags)");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not read {path}: {ex.Message}", ModManager.LogLevel.Error);
Entries = Array.Empty();
_byFlag = new();
}
}
/// The entry a stamped flag belongs to, or null. The @comment is already stripped by QuestManager.
public static QuestEntry? ByFlag(string flag)
{
if (string.IsNullOrEmpty(flag))
return null;
var idx = flag.IndexOf('@');
if (idx >= 0)
flag = flag[..idx];
return _byFlag.TryGetValue(flag.Trim(), out var e) ? e : null;
}
/// An entry by name, exact then prefix, case-insensitive. For /bounty-grant.
public static QuestEntry? ByName(string name)
{
name = name.Trim();
return Entries.FirstOrDefault(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
?? Entries.FirstOrDefault(e => e.Name.StartsWith(name, StringComparison.OrdinalIgnoreCase))
?? Entries.FirstOrDefault(e => e.Name.Contains(name, StringComparison.OrdinalIgnoreCase));
}
}