AccuracyModel.cs
1 namespace GUNRPG.Core.Combat; 2 3 /// <summary> 4 /// Models operator-driven accuracy effects that are applied AFTER weapon recoil is calculated. 5 /// Proficiency affects how effectively the operator counteracts recoil and stabilizes aim, 6 /// without modifying the weapon's base stats. 7 /// 8 /// This class provides testable, isolated logic for proficiency calculations that are used 9 /// by HitResolution.ResolveShotWithProficiency and Operator.UpdateRegeneration. 10 /// 11 /// Design goals: 12 /// - Weapon recoil values remain faithful and unchanged 13 /// - Operator proficiency determines how effectively recoil and gun kick are counteracted 14 /// - Accuracy proficiency feels like "player skill", not a flat accuracy buff 15 /// 16 /// Note: Deterministic calculation methods are static and don't require a Random instance. 17 /// Only sampling methods that need randomness require instantiation with a Random. 18 /// </summary> 19 public class AccuracyModel 20 { 21 /// <summary> 22 /// Maximum recoil control factor (cap at 60% reduction for highly proficient operators). 23 /// </summary> 24 public const float MaxRecoilControlFactor = 0.6f; 25 26 /// <summary> 27 /// Maximum aim error reduction factor at full proficiency (50% reduction). 28 /// This represents how much proficiency can reduce the aim error standard deviation. 29 /// </summary> 30 public const float MaxAimErrorReductionFactor = 0.5f; 31 32 /// <summary> 33 /// Maximum variance reduction factor at full proficiency (30% reduction). 34 /// This represents how much proficiency reduces recoil variance for more consistent shots. 35 /// </summary> 36 public const float MaxVarianceReductionFactor = 0.3f; 37 38 /// <summary> 39 /// Base aim error scale factor that converts (1 - accuracy) to standard deviation. 40 /// </summary> 41 public const float BaseAimErrorScale = 0.15f; 42 43 /// <summary> 44 /// Base recovery rate multiplier for a completely unskilled operator (proficiency = 0). 45 /// Higher proficiency = faster recoil recovery. 46 /// </summary> 47 public const float BaseRecoveryRateMultiplier = 0.5f; 48 49 /// <summary> 50 /// Maximum recovery rate multiplier (at proficiency = 1.0). 51 /// </summary> 52 public const float MaxRecoveryRateMultiplier = 2.0f; 53 54 /// <summary> 55 /// Default scale applied when converting impulse to flinch severity. 56 /// </summary> 57 public const float DefaultFlinchScale = 0.08f; 58 59 /// <summary> 60 /// Minimum factor of base proficiency that flinch can reduce to. 61 /// Ensures accuracy proficiency never drops below 35% of its base value. 62 /// </summary> 63 public const float FlinchProficiencyFloorFactor = 0.35f; 64 65 /// <summary> 66 /// Minimum flinch resistance to avoid divide-by-zero when calculating severity. 67 /// </summary> 68 public const float MinFlinchResistance = 0.01f; 69 70 private readonly Random _random; 71 72 /// <summary> 73 /// Initializes a new AccuracyModel with an injected random source for testability. 74 /// Only required when using sampling methods (SampleAimError, SampleGaussian). 75 /// </summary> 76 /// <param name="random">Random number generator for deterministic testing.</param> 77 public AccuracyModel(Random random) 78 { 79 _random = random ?? throw new ArgumentNullException(nameof(random)); 80 } 81 82 /// <summary> 83 /// Calculates the aim error standard deviation based on operator accuracy and proficiency. 84 /// Formula: (1 - accuracy) * BaseAimErrorScale * (1 - proficiency * MaxAimErrorReductionFactor) 85 /// 86 /// Higher accuracy = lower base error 87 /// Higher proficiency = further reduction in error (up to 50% at max proficiency) 88 /// </summary> 89 /// <param name="operatorAccuracy">Operator accuracy stat (0.0-1.0)</param> 90 /// <param name="accuracyProficiency">Operator accuracy proficiency (0.0-1.0)</param> 91 /// <returns>The aim error standard deviation in degrees.</returns> 92 public static float CalculateAimErrorStdDev(float operatorAccuracy, float accuracyProficiency) 93 { 94 operatorAccuracy = Math.Clamp(operatorAccuracy, 0f, 1f); 95 accuracyProficiency = Math.Clamp(accuracyProficiency, 0f, 1f); 96 97 // Base error from accuracy 98 float baseError = (1.0f - operatorAccuracy) * BaseAimErrorScale; 99 100 // Proficiency reduces error by up to MaxAimErrorReductionFactor (50%) 101 float proficiencyReduction = 1.0f - accuracyProficiency * MaxAimErrorReductionFactor; 102 103 return baseError * proficiencyReduction; 104 } 105 106 /// <summary> 107 /// Calculates the aim error standard deviation based on proficiency only. 108 /// This simplified version uses a linear interpolation from BaseAimErrorScale to a minimum value. 109 /// 110 /// Note: For production use with both accuracy and proficiency, use the overload that 111 /// accepts both parameters. 112 /// </summary> 113 /// <param name="accuracyProficiency">Operator accuracy proficiency (0.0-1.0)</param> 114 /// <returns>The aim error standard deviation in degrees.</returns> 115 public static float CalculateAimErrorStdDev(float accuracyProficiency) 116 { 117 accuracyProficiency = Math.Clamp(accuracyProficiency, 0f, 1f); 118 119 // Linear interpolation from BaseAimErrorScale (at 0) to minimum (at 1) 120 // Minimum error at max proficiency is BaseAimErrorScale * (1 - MaxAimErrorReductionFactor) = 0.075 121 float minError = BaseAimErrorScale * (1.0f - MaxAimErrorReductionFactor); 122 float stdDev = BaseAimErrorScale - (BaseAimErrorScale - minError) * accuracyProficiency; 123 return stdDev; 124 } 125 126 /// <summary> 127 /// Samples an aim acquisition error from a Gaussian distribution. 128 /// This error is applied to the initial shot placement. 129 /// Requires an AccuracyModel instance with a Random for sampling. 130 /// </summary> 131 /// <param name="accuracyProficiency">Operator accuracy proficiency (0.0-1.0)</param> 132 /// <returns>The aim error offset in degrees.</returns> 133 public float SampleAimError(float accuracyProficiency) 134 { 135 float stdDev = CalculateAimErrorStdDev(accuracyProficiency); 136 return SampleGaussian(0f, stdDev); 137 } 138 139 /// <summary> 140 /// Samples an aim acquisition error from a Gaussian distribution using both accuracy and proficiency. 141 /// Requires an AccuracyModel instance with a Random for sampling. 142 /// </summary> 143 /// <param name="operatorAccuracy">Operator accuracy stat (0.0-1.0)</param> 144 /// <param name="accuracyProficiency">Operator accuracy proficiency (0.0-1.0)</param> 145 /// <returns>The aim error offset in degrees.</returns> 146 public float SampleAimError(float operatorAccuracy, float accuracyProficiency) 147 { 148 float stdDev = CalculateAimErrorStdDev(operatorAccuracy, accuracyProficiency); 149 return SampleGaussian(0f, stdDev); 150 } 151 152 /// <summary> 153 /// Calculates the effective vertical recoil after operator counteraction. 154 /// Higher proficiency = more effective recoil counteraction. 155 /// 156 /// Formula: effectiveRecoil = weaponRecoil * (1 - proficiency * MaxRecoilControlFactor) 157 /// </summary> 158 /// <param name="weaponVerticalRecoil">The weapon's raw vertical recoil value (unchanged)</param> 159 /// <param name="accuracyProficiency">Operator accuracy proficiency (0.0-1.0)</param> 160 /// <returns>The effective vertical recoil after operator counteraction.</returns> 161 public static float CalculateEffectiveRecoil(float weaponVerticalRecoil, float accuracyProficiency) 162 { 163 accuracyProficiency = Math.Clamp(accuracyProficiency, 0f, 1f); 164 165 // effectiveRecoil = weaponRecoil * (1 - proficiency * MaxRecoilControlFactor) 166 // At proficiency 0: effectiveRecoil = weaponRecoil * 1.0 (no reduction) 167 // At proficiency 1: effectiveRecoil = weaponRecoil * (1 - 0.6) = weaponRecoil * 0.4 (60% reduction) 168 float reductionFactor = CalculateRecoilReductionFactor(accuracyProficiency); 169 return weaponVerticalRecoil * reductionFactor; 170 } 171 172 /// <summary> 173 /// Calculates the recoil reduction factor based on proficiency. 174 /// At proficiency 0: returns 1.0 (no reduction) 175 /// At proficiency 1: returns 0.4 (60% reduction) 176 /// </summary> 177 /// <param name="accuracyProficiency">Operator accuracy proficiency (0.0-1.0)</param> 178 /// <returns>The recoil reduction factor to multiply against weapon recoil.</returns> 179 public static float CalculateRecoilReductionFactor(float accuracyProficiency) 180 { 181 accuracyProficiency = Math.Clamp(accuracyProficiency, 0f, 1f); 182 return 1f - accuracyProficiency * MaxRecoilControlFactor; 183 } 184 185 /// <summary> 186 /// Calculates the effective variance after proficiency reduction. 187 /// At proficiency 1: variance reduced by MaxVarianceReductionFactor (30%) 188 /// </summary> 189 /// <param name="baseVariance">The base variance before proficiency adjustment</param> 190 /// <param name="accuracyProficiency">Operator accuracy proficiency (0.0-1.0)</param> 191 /// <returns>The effective variance after proficiency reduction.</returns> 192 public static float CalculateEffectiveVariance(float baseVariance, float accuracyProficiency) 193 { 194 accuracyProficiency = Math.Clamp(accuracyProficiency, 0f, 1f); 195 return baseVariance * (1.0f - accuracyProficiency * MaxVarianceReductionFactor); 196 } 197 198 /// <summary> 199 /// Calculates the gun kick recovery rate multiplier based on operator proficiency. 200 /// Higher proficiency = faster recovery toward baseline aim. 201 /// </summary> 202 /// <param name="accuracyProficiency">Operator accuracy proficiency (0.0-1.0)</param> 203 /// <returns>Multiplier to apply to base recoil recovery rate.</returns> 204 public static float CalculateRecoveryRateMultiplier(float accuracyProficiency) 205 { 206 accuracyProficiency = Math.Clamp(accuracyProficiency, 0f, 1f); 207 208 // Linear interpolation from BaseRecoveryRateMultiplier (at 0) to MaxRecoveryRateMultiplier (at 1) 209 return BaseRecoveryRateMultiplier + (MaxRecoveryRateMultiplier - BaseRecoveryRateMultiplier) * accuracyProficiency; 210 } 211 212 /// <summary> 213 /// Calculates flinch severity from incoming damage (used as an impulse proxy) and defender flinch resistance. 214 /// Formula: severity = Clamp01((incomingDamage / flinchResistance) * flinchScale) 215 /// </summary> 216 public static float CalculateFlinchSeverity(float incomingDamage, float flinchResistance, float flinchScale = DefaultFlinchScale) 217 { 218 float resistance = Math.Max(flinchResistance, MinFlinchResistance); 219 float effectiveImpulse = incomingDamage / resistance; 220 float severity = effectiveImpulse * flinchScale; 221 return Math.Clamp(severity, 0f, 1f); 222 } 223 224 /// <summary> 225 /// Calculates effective accuracy proficiency after applying flinch severity. 226 /// Formula: effective = baseAccuracyProficiency * max(1 - flinchSeverity, minProficiencyFactor) 227 /// </summary> 228 /// <param name="baseAccuracyProficiency">Operator base proficiency (0.0-1.0).</param> 229 /// <param name="flinchSeverity">Flinch severity (0.0-1.0).</param> 230 /// <param name="minProficiencyFactor">Minimum factor of base proficiency to retain.</param> 231 /// <returns>Effective accuracy proficiency after flinch.</returns> 232 public static float CalculateEffectiveAccuracyProficiency( 233 float baseAccuracyProficiency, 234 float flinchSeverity, 235 float minProficiencyFactor = FlinchProficiencyFloorFactor) 236 { 237 baseAccuracyProficiency = Math.Clamp(baseAccuracyProficiency, 0f, 1f); 238 float severityFactor = 1f - Math.Clamp(flinchSeverity, 0f, 1f); 239 float clampedFactor = Math.Max(severityFactor, Math.Clamp(minProficiencyFactor, 0f, 1f)); 240 return Math.Clamp(baseAccuracyProficiency * clampedFactor, 0f, 1f); 241 } 242 243 /// <summary> 244 /// Applies proficiency-based recovery to the current accumulated recoil. 245 /// Called during recoil recovery phase to adjust how quickly the operator stabilizes. 246 /// </summary> 247 /// <param name="currentRecoilY">Current accumulated vertical recoil</param> 248 /// <param name="baseRecoveryAmount">The base recovery amount before proficiency adjustment</param> 249 /// <param name="accuracyProficiency">Operator accuracy proficiency (0.0-1.0)</param> 250 /// <returns>The new recoil value after proficiency-enhanced recovery.</returns> 251 public static float ApplyRecovery(float currentRecoilY, float baseRecoveryAmount, float accuracyProficiency) 252 { 253 float multiplier = CalculateRecoveryRateMultiplier(accuracyProficiency); 254 float adjustedRecovery = baseRecoveryAmount * multiplier; 255 256 if (currentRecoilY > 0) 257 return Math.Max(0, currentRecoilY - adjustedRecovery); 258 if (currentRecoilY < 0) 259 return Math.Min(0, currentRecoilY + adjustedRecovery); 260 261 return currentRecoilY; 262 } 263 264 /// <summary> 265 /// Samples from a Gaussian (normal) distribution using the Box-Muller transform. 266 /// Requires an AccuracyModel instance with a Random for sampling. 267 /// </summary> 268 /// <param name="mean">The mean of the distribution</param> 269 /// <param name="stdDev">The standard deviation of the distribution</param> 270 /// <returns>A sample from the Gaussian distribution.</returns> 271 public float SampleGaussian(float mean, float stdDev) 272 { 273 // Box-Muller transform 274 double u1 = 1.0 - _random.NextDouble(); // Uniform(0,1] 275 double u2 = 1.0 - _random.NextDouble(); 276 double randStdNormal = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2); 277 return mean + stdDev * (float)randStdNormal; 278 } 279 280 /// <summary> 281 /// Static helper for sampling from a Gaussian distribution when you have your own Random instance. 282 /// </summary> 283 /// <param name="random">Random number generator</param> 284 /// <param name="mean">The mean of the distribution</param> 285 /// <param name="stdDev">The standard deviation of the distribution</param> 286 /// <returns>A sample from the Gaussian distribution.</returns> 287 public static float SampleGaussian(Random random, float mean, float stdDev) 288 { 289 // Box-Muller transform 290 double u1 = 1.0 - random.NextDouble(); // Uniform(0,1] 291 double u2 = 1.0 - random.NextDouble(); 292 double randStdNormal = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2); 293 return mean + stdDev * (float)randStdNormal; 294 } 295 }