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)); } }