namespace Aeshnidae.QuestBonus;
///
/// Keeps each character's quest-point total in sync, and applies it to XP.
///
/// All four QuestManager mutators are handled with the same prefix/postfix pair:
/// record the solve count before, compare after, and only move the total when a
/// quest crosses the 0 <-> solved boundary. Aquafir's original handled Update and
/// SetQuestCompletions this way but special-cased Decrement and Erase, where the
/// Decrement branch added points on removal instead of subtracting them. Treating
/// all four identically removes that class of bug.
///
[HarmonyPatch]
public static class Patches
{
#region Quest point tracking
[HarmonyPrefix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Update), new[] { typeof(string) })]
public static void PreUpdate(string questFormat, QuestManager __instance, ref int __state) =>
__state = SolvesOf(__instance, questFormat);
[HarmonyPostfix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Update), new[] { typeof(string) })]
public static void PostUpdate(string questFormat, QuestManager __instance, int __state) =>
ApplyDelta(__instance, questFormat, __state);
[HarmonyPrefix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.SetQuestCompletions), new[] { typeof(string), typeof(int) })]
public static void PreSetQuestCompletions(string questFormat, QuestManager __instance, ref int __state) =>
__state = SolvesOf(__instance, questFormat);
[HarmonyPostfix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.SetQuestCompletions), new[] { typeof(string), typeof(int) })]
public static void PostSetQuestCompletions(string questFormat, QuestManager __instance, int __state) =>
ApplyDelta(__instance, questFormat, __state);
[HarmonyPrefix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Decrement), new[] { typeof(string), typeof(int) })]
public static void PreDecrement(string quest, QuestManager __instance, ref int __state) =>
__state = SolvesOf(__instance, quest);
[HarmonyPostfix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Decrement), new[] { typeof(string), typeof(int) })]
public static void PostDecrement(string quest, QuestManager __instance, int __state) =>
ApplyDelta(__instance, quest, __state);
[HarmonyPrefix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Erase), new[] { typeof(string) })]
public static void PreErase(string questFormat, QuestManager __instance, ref int __state) =>
__state = SolvesOf(__instance, questFormat);
[HarmonyPostfix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Erase), new[] { typeof(string) })]
public static void PostErase(string questFormat, QuestManager __instance, int __state) =>
ApplyDelta(__instance, questFormat, __state);
private static int SolvesOf(QuestManager manager, string questFormat)
{
// Only players carry a bonus; NPCs and generators have quest registries too.
if (manager?.Creature is not Player)
return 0;
try
{
return manager.GetCurrentSolves(questFormat);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not read solves for '{questFormat}': {ex.Message}", ModManager.LogLevel.Warn);
return 0;
}
}
///
/// Moves the player's total only when the quest crosses unsolved <-> solved.
/// Repeat completions of an already-solved quest are worth nothing, which is
/// what keeps timers and dailies from inflating the bonus.
///
private static void ApplyDelta(QuestManager manager, string questFormat, int solvesBefore)
{
if (manager?.Creature is not Player player)
return;
try
{
var solvesAfter = manager.GetCurrentSolves(questFormat);
var wasSolved = solvesBefore != 0;
var isSolved = solvesAfter != 0;
if (wasSolved == isSolved)
return;
var weight = QuestBonusExtensions.WeightOf(questFormat);
if (weight == 0)
return;
var delta = isSolved ? weight : -weight;
player.AddQuestPoints(delta);
if (Mod.Settings.NotifyQuest)
{
var template = isSolved ? Mod.Settings.QuestGainedMessage : Mod.Settings.QuestLostMessage;
var message = FormatMessage(template, QuestManager.GetQuestName(questFormat), Math.Abs(delta), player);
if (!string.IsNullOrWhiteSpace(message))
player.SendMessage(message);
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] quest bonus update failed for '{questFormat}': {ex}", ModManager.LogLevel.Error);
}
}
///
/// Fills the {quest} / {points} / {bonus} / {qp} placeholders in the message
/// templates from Settings.json. Called after the total has been updated, so
/// {bonus} and {qp} report the new values.
///
private static string FormatMessage(string template, string questName, double points, Player player)
{
if (string.IsNullOrWhiteSpace(template))
return "";
return template
.Replace("{quest}", questName)
.Replace("{points}", points.ToString("0.##"))
.Replace("{bonus}", player.QuestBonusText())
.Replace("{qp}", player.GetQuestPoints().ToString("0.##"));
}
#endregion
///
/// Recalculate from scratch on login, so hand-edited characters and any missed
/// increments self-correct.
///
/// Aquafir's original called CalculateQuestPoints() here and discarded the
/// result - it is a pure sum, so the stored property was never actually
/// refreshed. ResyncQuestPoints is the one that writes.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), nameof(Player.PlayerEnterWorld))]
public static void PostPlayerEnterWorld(Player __instance)
{
try
{
__instance.ResyncQuestPoints();
}
catch (Exception ex)
{
// Never throw on the login path - it would look like an ACE bug.
ModManager.Log($"[{Mod.Name}] login resync failed for {__instance?.Name}: {ex}", ModManager.LogLevel.Error);
}
}
///
/// Apply the multiplier to earned XP.
///
/// GrantXP re-enters itself: when the player is in an XP-sharing fellowship it
/// hands off to Fellowship.SplitXp, which calls GrantXP again for each member
/// with ShareType.Fellowship cleared. Multiplying on the outer call as well as
/// the inner one would square the bonus for fellowed players, so the outer call
/// is skipped and each member's share is scaled by their own bonus instead.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Player), nameof(Player.GrantXP), new[] { typeof(long), typeof(XpType), typeof(ShareType) })]
public static void PreGrantXP(ref long amount, XpType xpType, ShareType shareType, Player __instance)
{
try
{
if (amount <= 0 || __instance is null)
return;
// Olthoi players earn no XP here; the call returns early.
if (__instance.IsOlthoiPlayer)
return;
// About to be split across the fellowship and re-dispatched - don't
// scale it now or the bonus applies twice.
if (__instance.Fellowship is not null && __instance.Fellowship.ShareXP && shareType.HasFlag(ShareType.Fellowship))
return;
var multiplier = __instance.QuestBonusMultiplier();
if (multiplier <= 1)
return;
var boosted = (long)(amount * multiplier);
if (Mod.Settings.NotifyExp)
__instance.SendMessage($"Quest bonus: {amount:N0} -> {boosted:N0} XP ({__instance.QuestBonusText()}).");
amount = boosted;
}
catch (Exception ex)
{
// Leave amount untouched on failure rather than eating someone's XP.
ModManager.Log($"[{Mod.Name}] XP boost failed for {__instance?.Name}: {ex}", ModManager.LogLevel.Error);
}
}
}