using System; using System.Collections.Generic; using System.Diagnostics; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security; using System.Security.Permissions; using BepInEx; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using Photon.Pun; using ScuttlePenalty.Core; using ScuttlePenalty.Utils; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.DisableOptimizations | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyCompany("ScuttlePenalty")] [assembly: AssemblyConfiguration("Debug")] [assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyInformationalVersion("1.0.0+fe42d853a6b27dae0bba338b4f740d2abb3fac53")] [assembly: AssemblyProduct("ScuttlePenalty")] [assembly: AssemblyTitle("ScuttlePenalty")] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("1.0.0.0")] [module: UnverifiableCode] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)] internal sealed class NullableAttribute : Attribute { public readonly byte[] NullableFlags; public NullableAttribute(byte P_0) { NullableFlags = new byte[1] { P_0 }; } public NullableAttribute(byte[] P_0) { NullableFlags = P_0; } } [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)] internal sealed class NullableContextAttribute : Attribute { public readonly byte Flag; public NullableContextAttribute(byte P_0) { Flag = P_0; } } [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace ScuttlePenalty { [BepInPlugin("jill920.scuttlepenalty", "Scuttle Penalty", "1.5.0")] public class ScuttlePenaltyPlugin : BaseUnityPlugin { public const string MOD_GUID = "jill920.scuttlepenalty"; public const string MOD_NAME = "Scuttle Penalty"; public const string MOD_VERSION = "1.5.0"; public static ScuttlePenaltyPlugin Instance; public static ManualLogSource Logger; public static bool DebugMode; public const float REGRAB_PENALTY = 0.28f; public const float REGRAB_WINDOW_MS = 500f; public const int PENALTY_START_AT = 2; public const float REGRAB_PENALTY_MULTIPLIER = 0.5f; public const float MAX_REGRAB_PENALTY_MULTIPLIER = 3.5f; public const float MIN_PITCH_FOR_PENALTY = 30f; public const float MAX_PITCH_FOR_EXPLOIT = -30f; public const float TENDERFOOT_BASELINE = 0.14f; public const float MAX_ANGLE_MULTIPLIER = 2.5f; public const float MIN_ANGLE_MULTIPLIER = 0.1f; public const float MIN_OVERDRAIN_TO_REFUND = 0.02f; public const float REFUND_PERCENTAGE = 0.95f; public const float MAX_REFUND_PER_EVENT = 0.35f; public const float MAX_REFUND_PER_WINDOW = 0.6f; public const float REFUND_WINDOW_SECONDS = 10f; public const float MIN_CLIMB_DURATION_SECONDS = 0.2f; public const float MAX_COMPENSATION_PER_WINDOW = 0.5f; public const float COMPENSATION_WINDOW_SECONDS = 10f; public const float MIN_IMMEDIATE_COMPENSATION = 0.08f; public const float MAX_IMMEDIATE_COMPENSATION = 0.25f; private void Awake() { Instance = this; Logger = ((BaseUnityPlugin)this).Logger; string[] commandLineArgs = Environment.GetCommandLineArgs(); string[] array = commandLineArgs; foreach (string text in array) { if (text.Equals("-ScuttleDebug", StringComparison.OrdinalIgnoreCase)) { DebugMode = true; break; } } Harmony.CreateAndPatchAll(Assembly.GetExecutingAssembly(), "jill920.scuttlepenalty"); Logger.LogInfo((object)"[Scuttle Penalty 1.5.0] Loaded"); Logger.LogInfo((object)(" Debug Mode: " + (DebugMode ? "ENABLED" : "DISABLED"))); Logger.LogInfo((object)$" Penalty: {0.28f} stamina per re-grab (starting at re-grab #{3})"); Logger.LogInfo((object)$" Immediate Compensation: {0.08f}-{0.25f} stamina on forced re-grabs"); Logger.LogInfo((object)$" Final Refund: Returns {95f}% of remaining over-drain"); } } } namespace ScuttlePenalty.Utils { public static class StaminaHelper { private static MethodInfo? _useStaminaMethod; public static void ApplyStaminaPenalty(Character character, float amount) { if (_useStaminaMethod == null) { _useStaminaMethod = typeof(Character).GetMethod("UseStamina", BindingFlags.Instance | BindingFlags.NonPublic); } if (_useStaminaMethod != null) { _useStaminaMethod.Invoke(character, new object[2] { amount, true }); } else { character.data.currentStamina = Mathf.Max(0f, character.data.currentStamina - amount); } } public static void ApplyStaminaRefund(Character character, float amount) { if (!(amount <= 0.001f)) { character.AddStamina(amount); } } } } namespace ScuttlePenalty.Patches { [HarmonyPatch(typeof(CharacterClimbing))] internal static class CharacterClimbing_StartPatch { private static void LogDebug(string message) { if (ScuttlePenaltyPlugin.DebugMode) { ScuttlePenaltyPlugin.Logger.LogInfo((object)message); } } private static void LogWarning(string message) { ScuttlePenaltyPlugin.Logger.LogWarning((object)message); } private static ReGrabTracker GetTracker(Character character) { int viewID = ((MonoBehaviourPun)character).photonView.ViewID; if (!SharedState.Trackers.ContainsKey(viewID)) { SharedState.Trackers[viewID] = new ReGrabTracker(); } return SharedState.Trackers[viewID]; } private static CompensationTracker GetCompensationTracker(Character character) { int viewID = ((MonoBehaviourPun)character).photonView.ViewID; if (!SharedState.CompensationTrackers.ContainsKey(viewID)) { SharedState.CompensationTrackers[viewID] = new CompensationTracker(); } return SharedState.CompensationTrackers[viewID]; } [HarmonyPatch("StartClimbRpc")] [HarmonyPrefix] private static void Prefix(CharacterClimbing __instance, Vector3 climbPos, Vector3 climbNormal) { Character component = ((Component)__instance).GetComponent(); if ((Object)(object)component == (Object)null || !component.IsLocal) { return; } ReGrabTracker tracker = GetTracker(component); int viewID = ((MonoBehaviourPun)component).photonView.ViewID; float time = Time.time; float currentStamina = component.data.currentStamina; float extraStamina = component.data.extraStamina; tracker.StartNewClimb(time, currentStamina, extraStamina); bool flag = SharedState.LastClimbEndTime.ContainsKey(viewID) && SharedState.LastClimbEndTime[viewID] > 0f; float num = (flag ? (time - SharedState.LastClimbEndTime[viewID]) : 999f); bool flag2 = flag && num < 0.5f; string text = (SharedState.LastStopReason.ContainsKey(viewID) ? SharedState.LastStopReason[viewID] : ""); if (flag2) { float num2 = num * 1000f; if (text == "MOUSE1") { tracker.RegisterReGrab(time); int consecutiveReGrabs = tracker.ConsecutiveReGrabs; if (consecutiveReGrabs >= 2) { float num3 = Mathf.Min(1f + (float)(consecutiveReGrabs - 2) * 0.5f, 3.5f); float num4 = 0.28f * num3; StaminaHelper.ApplyStaminaPenalty(component, num4); LogWarning($"PENALTY #{consecutiveReGrabs}: -{num4:F2} stamina (re-grab after {num2:F0}ms)"); } else { LogDebug($"RE-GRAB #{consecutiveReGrabs}: FREE (wall switch) - {num2:F0}ms after MOUSE1 release"); } return; } tracker.CurrentClimbIsForcedReGrab = true; float y = component.data.lookValues.y; LogDebug($"FORCED RE-GRAB DETECTED: Pitch={y:F1}°, lastReason={text}, timeSinceStop={num2:F0}ms"); if (y > 30f) { float num5 = Mathf.Clamp01((y - 30f) / 60f); float num6 = Mathf.Lerp(0.08f, 0.25f, num5); CompensationTracker compensationTracker = GetCompensationTracker(component); compensationTracker.ResetWindow(time); if (compensationTracker.CanCompensate(num6)) { StaminaHelper.ApplyStaminaRefund(component, num6); compensationTracker.AddCompensation(num6); tracker.ImmediateCompensationGiven = num6; LogWarning($"IMMEDIATE COMPENSATION: +{num6:F3} stamina (pitch={y:F1}°)"); } else if (ScuttlePenaltyPlugin.DebugMode) { LogDebug("IMMEDIATE COMPENSATION SKIPPED: Rate limit exceeded"); } } if (tracker.ConsecutiveReGrabs > 0) { LogDebug("RE-GRAB CHAIN RESET: Previous stop was " + text); tracker.Reset(); } } else if (tracker.ConsecutiveReGrabs > 0 && ScuttlePenaltyPlugin.DebugMode) { LogDebug("RE-GRAB CHAIN RESET: Long delay between climbs"); tracker.Reset(); } } } [HarmonyPatch(typeof(CharacterClimbing))] internal static class CharacterClimbing_StopPatch { private static void LogDebug(string message) { if (ScuttlePenaltyPlugin.DebugMode) { ScuttlePenaltyPlugin.Logger.LogInfo((object)message); } } private static void LogWarning(string message) { ScuttlePenaltyPlugin.Logger.LogWarning((object)message); } private static ReGrabTracker GetTracker(Character character) { int viewID = ((MonoBehaviourPun)character).photonView.ViewID; if (!SharedState.Trackers.ContainsKey(viewID)) { SharedState.Trackers[viewID] = new ReGrabTracker(); } return SharedState.Trackers[viewID]; } [HarmonyPatch("StopClimbingRpc")] [HarmonyPrefix] private static void Prefix(CharacterClimbing __instance, float setFall) { Character component = ((Component)__instance).GetComponent(); if ((Object)(object)component == (Object)null || !component.IsLocal) { return; } ReGrabTracker tracker = GetTracker(component); int viewID = ((MonoBehaviourPun)component).photonView.ViewID; float time = Time.time; float currentStamina = component.data.currentStamina; float extraStamina = component.data.extraStamina; tracker.EndCurrentClimb(time, currentStamina, extraStamina); float duration = tracker.CurrentClimb.Duration; float totalStaminaLost = tracker.CurrentClimb.TotalStaminaLost; float totalStartStamina = tracker.CurrentClimb.TotalStartStamina; string text = "OTHER"; if (component.input.jumpWasPressed) { text = "JUMP"; } else if (component.input.usePrimaryWasReleased) { text = "MOUSE1"; } else if (setFall > 0f) { text = "VAULT"; } LogDebug($"CLIMB STOPPED: Reason={text}, Duration={duration * 1000f:F0}ms, " + $"Loss={totalStaminaLost:F3}, StartStam={totalStartStamina:F3}, EndStam={currentStamina:F3}"); if (tracker.CurrentClimbIsForcedReGrab) { float y = component.data.lookValues.y; LogDebug($"FORCED RE-GRAB CLIMB ENDED: Pitch={y:F1}°, Duration={duration * 1000f:F0}ms, Loss={totalStaminaLost:F3}"); if (y > 30f && duration >= 0.2f && totalStaminaLost > 0f) { float num = RefundCalculator.CalculateRefund(duration, totalStaminaLost, totalStartStamina, y); if (tracker.ImmediateCompensationGiven > 0f) { float num2 = num; num = Mathf.Max(0f, num - tracker.ImmediateCompensationGiven); LogDebug($"ADJUSTING REFUND: Immediate given={tracker.ImmediateCompensationGiven:F3}, " + $"Original refund={num2:F3}, Final refund={num:F3}"); } if (num > 0f) { tracker.Refund.ResetWindow(time); if (tracker.Refund.CanRefund(num)) { StaminaHelper.ApplyStaminaRefund(component, num); tracker.Refund.AddRefund(num); float num3 = RefundCalculator.CalculateExpectedLoss(duration, y); float num4 = totalStaminaLost - num3; LogWarning($"FINAL REFUND | Pitch={y:F1}° | Duration={duration * 1000f:F0}ms | " + $"Expected={num3:F3} | Actual={totalStaminaLost:F3} | Over={num4:F3} | " + $"Refund={num:F3} | Total={tracker.Refund.RefundAmountInWindow:F3}/{0.6f:F3}"); } else if (ScuttlePenaltyPlugin.DebugMode) { LogDebug("REFUND SKIPPED: Rate limit exceeded"); } } else if (ScuttlePenaltyPlugin.DebugMode) { LogDebug("REFUND SKIPPED: Calculated refund amount was 0 after subtracting immediate compensation"); } } else if (ScuttlePenaltyPlugin.DebugMode) { LogDebug($"REFUND SKIPPED: Conditions not met - pitch={y:F1}°, duration={duration:F2}s, loss={totalStaminaLost:F3}"); } tracker.CurrentClimbIsForcedReGrab = false; tracker.ImmediateCompensationGiven = 0f; } SharedState.LastClimbEndTime[viewID] = time; SharedState.LastStopReason[viewID] = text; } } } namespace ScuttlePenalty.Core { public class ClimbData { public float StartTime { get; set; } public float EndTime { get; set; } public float StartStamina { get; set; } public float EndStamina { get; set; } public float StartBonus { get; set; } public float EndBonus { get; set; } public float Duration => EndTime - StartTime; public float TotalStaminaLost => StartStamina + StartBonus - (EndStamina + EndBonus); public float TotalStartStamina => StartStamina + StartBonus; public void Reset() { StartTime = 0f; EndTime = 0f; StartStamina = 1f; EndStamina = 1f; StartBonus = 0f; EndBonus = 0f; } public void RecordStart(float time, float stamina, float bonus) { StartTime = time; StartStamina = stamina; StartBonus = bonus; } public void RecordEnd(float time, float stamina, float bonus) { EndTime = time; EndStamina = stamina; EndBonus = bonus; } } public class CompensationTracker { public float LastCompensationTime { get; private set; } = -10f; public float CompensationAmountInWindow { get; private set; } = 0f; public void ResetWindow(float currentTime) { if (currentTime - LastCompensationTime > 10f) { CompensationAmountInWindow = 0f; LastCompensationTime = currentTime; } } public bool CanCompensate(float amount) { return CompensationAmountInWindow + amount <= 0.6f; } public void AddCompensation(float amount) { CompensationAmountInWindow += amount; } } public static class RefundCalculator { public static float GetDifficultyBaseline() { float num = Ascents.climbStaminaMultiplier; if (RunSettings.GetValue((SETTINGTYPE)20000, false) == 1) { num *= 4f; } return 0.14f * num; } public static float GetExpectedDrainRate(float pitch) { float difficultyBaseline = GetDifficultyBaseline(); if (pitch >= -30f && pitch <= 30f) { return difficultyBaseline; } if (pitch > 30f) { float num = 1f + (pitch - 30f) / 60f * 1.5f; return difficultyBaseline * Mathf.Min(num, 2.5f); } if (pitch < -30f) { float num2 = 1f - (Mathf.Abs(pitch) - Mathf.Abs(-30f)) / 60f * 0.9f; return difficultyBaseline * Mathf.Max(num2, 0.1f); } return difficultyBaseline; } public static float CalculateExpectedLoss(float duration, float pitch) { return duration * GetExpectedDrainRate(pitch); } public static float CalculateRefund(float duration, float actualLoss, float startStamina, float pitch) { float num = CalculateExpectedLoss(duration, pitch); float num2 = actualLoss - num; if (num2 <= 0.02f) { return 0f; } float num3 = num2 * 0.95f; num3 = Mathf.Min(num3, 0.35f); float num4 = Mathf.Max(0f, startStamina - (startStamina - actualLoss)); return Mathf.Min(num3, num4); } } public class RefundTracker { public float LastRefundTime { get; private set; } = -10f; public float RefundAmountInWindow { get; private set; } = 0f; public void ResetWindow(float currentTime) { if (currentTime - LastRefundTime > 10f) { RefundAmountInWindow = 0f; LastRefundTime = currentTime; } } public bool CanRefund(float amount) { return RefundAmountInWindow + amount <= 0.6f; } public void AddRefund(float amount) { RefundAmountInWindow += amount; } } public class ReGrabTracker { public int ConsecutiveReGrabs { get; set; } = 0; public float LastReGrabTime { get; set; } = 0f; public bool CurrentClimbIsForcedReGrab { get; set; } = false; public float ImmediateCompensationGiven { get; set; } = 0f; public ClimbData CurrentClimb { get; private set; } public RefundTracker Refund { get; private set; } public ReGrabTracker() { CurrentClimb = new ClimbData(); Refund = new RefundTracker(); CurrentClimb.Reset(); } public void Reset() { ConsecutiveReGrabs = 0; LastReGrabTime = 0f; CurrentClimbIsForcedReGrab = false; ImmediateCompensationGiven = 0f; } public void RegisterReGrab(float currentTime) { ConsecutiveReGrabs++; LastReGrabTime = currentTime; } public void StartNewClimb(float time, float stamina, float bonus) { CurrentClimb.RecordStart(time, stamina, bonus); CurrentClimbIsForcedReGrab = false; ImmediateCompensationGiven = 0f; } public void EndCurrentClimb(float time, float stamina, float bonus) { CurrentClimb.RecordEnd(time, stamina, bonus); } } public static class SharedState { public static Dictionary LastClimbEndTime { get; } = new Dictionary(); public static Dictionary LastStopReason { get; } = new Dictionary(); public static Dictionary Trackers { get; } = new Dictionary(); public static Dictionary CompensationTrackers { get; } = new Dictionary(); } }