namespace Aeshnidae.AllegianceBounty;
///
/// Two hooks: where a quest completion is written, and where kill experience lands.
///
/// Both observe more than they change. The stamp hook changes nothing at all; the
/// experience hook only ever adds, and only on a Kill grant with an active bounty, so a
/// failure here costs the bonus and nothing else.
///
[HarmonyPatch]
public static class Patches
{
// ------------------------------------------------------------ completions
///
/// Every completion write in ACE funnels through QuestManager.Update(string):
/// the StampQuest emote calls QuestManager.Stamp, which is Update (QuestManager.cs:484);
/// the IncrementQuest emote calls Increment, a loop of Update (:367); a kill task's
/// counter goes through HandleKillTask -> Stamp -> Update (:583, :608); the UpdateQuest
/// emote and an item pickup with a quest stamp call Update or Stamp directly
/// (EmoteManager.cs:1475/1484, OnQuest). SetQuestCompletions is a separate method that
/// initialises a counter (a kill task's "0 of 20") and is deliberately not hooked: no
/// listed flag is written by it.
///
/// Update both creates and increments and has no "this was a completion" signal, so
/// the solve count is read before and after and a completion is the count going up -
/// the same before/after shape Aeshnidae.Bank uses on the same method. A stamp on a
/// flag at its MaxSolves returns early without incrementing, so a repeat of a
/// once-only quest is not a completion; a stamp on a timer flag before the timer is
/// up never reaches Update, the NPC refuses first.
///
/// A kill task's counter ticks through the same method once per kill; the counter
/// flag is not in the list, the completion timer the turn-in stamps is, so the kill
/// ticks cost a dictionary miss each and nothing else.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Update), new[] { typeof(string) })]
public static void PreQuestUpdate(string questFormat, QuestManager __instance, ref int __state)
{
__state = SolvesOf(__instance, questFormat);
}
[HarmonyPostfix]
[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Update), new[] { typeof(string) })]
public static void PostQuestUpdate(string questFormat, QuestManager __instance, int __state)
{
if (!Mod.Settings.Enabled)
return;
// NPCs, generators and fellowships keep quest registries too; only a player is sworn.
if (__instance?.Creature is not Player player)
return;
try
{
if (__instance.GetCurrentSolves(questFormat) <= __state)
return;
var flag = QuestManager.GetQuestName(questFormat);
// The allegiance layer: only a listed completion. The personal layer: any
// flag written, the first time today (Personal decides).
Bounty.OnStamp(player, flag);
Personal.OnStamp(player, flag);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not handle the stamp of '{questFormat}' for {player.Name}: {ex}", ModManager.LogLevel.Error);
}
}
private static int SolvesOf(QuestManager manager, string questFormat)
{
if (manager?.Creature is not Player)
return 0;
try
{
return manager.GetCurrentSolves(questFormat);
}
catch
{
return 0;
}
}
// ------------------------------------------------------------ experience
///
/// The type of the OUTER grant of a fellowship split, while the split's inner grants
/// run. GrantXP with ShareType.Fellowship set hands the whole amount to
/// Fellowship.SplitXp (Player_Xp.cs:63-68), which re-enters GrantXP synchronously on
/// the same thread, once per member, with the flag removed: the earner's slice keeps
/// its type, every other member's arrives as XpType.Fellowship. So a Fellowship-typed
/// grant is a share of whatever the outer grant was - a kill or a quest turn-in - and
/// this is how the inner call knows which.
///
[ThreadStatic]
private static XpType? _splitOuterType;
private static bool IsSplitOuter(Player player, ShareType shareType) =>
player.Fellowship != null && player.Fellowship.ShareXP && shareType.HasFlag(ShareType.Fellowship);
///
/// Adds the allegiance's bounty and the character's personal bounty to a kill before it lands.
///
/// Hooked on Player.GrantXP because it is the one funnel every award passes through
/// after xp_modifier and the enchantments (EarnXP applies those and calls it), and
/// before UpdateXpAndLevel, allegiance passup and item experience read the amount -
/// so what the bounty adds is what the bar, the patron's share, the items and
/// Aeshnidae.Bank's Radiance all see. A prefix on the amount, as Aeshnidae.QuestBonus
/// did, rather than a second grant: one number lands, one kill line is printed.
///
/// Applied on the INNER grants of a fellowship split only. The outer call grants
/// nothing itself; scaling it too would bonus the sharer's allegiance on everybody's
/// slice and then each member's allegiance again. Each member's slice is scaled by
/// their own allegiance's bounty instead, which is what "every member of the
/// allegiance" means when the fellowship crosses allegiances. The share Instances
/// pays other players in a dungeon copy is a plain Kill grant with ShareType.None
/// and is bonused by the receiver's allegiance like any kill.
///
/// Not applied to Quest (unless ApplyToQuestExperience), Allegiance passup (the
/// vassal's kill was already bonused and the patron's share is computed from it),
/// Proficiency, Emote or Admin.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Player), nameof(Player.GrantXP), new[] { typeof(long), typeof(XpType), typeof(ShareType) })]
public static void PreGrantXP(Player __instance, ref long amount, XpType xpType, ShareType shareType)
{
try
{
if (__instance is null || amount <= 0 || __instance.IsOlthoiPlayer)
return;
if (IsSplitOuter(__instance, shareType))
{
_splitOuterType = xpType;
return;
}
if (!Mod.Settings.Enabled || !BountyDb.Ready)
return;
var s = Mod.Settings;
var applies = xpType switch
{
XpType.Kill => true,
XpType.Quest => s.ApplyToQuestExperience,
XpType.Fellowship => s.ApplyToFellowshipShares &&
(_splitOuterType == XpType.Kill || (_splitOuterType == XpType.Quest && s.ApplyToQuestExperience)),
_ => false,
};
if (!applies)
return;
// Additive percents: the allegiance's bounty plus the character's own day.
var percent = Bounty.EffectivePercent(__instance) + Personal.Percent(__instance);
if (percent <= 0)
return;
var bonus = (long)Math.Round(amount * percent / 100.0);
if (bonus > 0)
amount += bonus;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not apply the bounty for {__instance?.Name ?? "?"}: {ex.Message}", ModManager.LogLevel.Warn);
}
}
// ------------------------------------------------------------ the personal cache
///
/// Read the character's day once at login so the kill path only ever reads a
/// dictionary; login already touches the database heavily, a landblock tick must not.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), nameof(Player.PlayerEnterWorld))]
public static void PostEnterWorld(Player __instance)
{
if (Mod.Settings.Enabled && Mod.Settings.PersonalEnabled)
Personal.Load(__instance);
}
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), nameof(Player.LogOut_Inner))]
public static void PostLogOut(Player __instance) => Personal.Forget(__instance);
/// The outer split has returned; the inner grants are done. A finalizer so an exception inside cannot leave the type set.
[HarmonyFinalizer]
[HarmonyPatch(typeof(Player), nameof(Player.GrantXP), new[] { typeof(long), typeof(XpType), typeof(ShareType) })]
public static void FinalGrantXP(Player __instance, ShareType shareType)
{
if (__instance is not null && IsSplitOuter(__instance, shareType))
_splitOuterType = null;
}
}