/ GUNRPG.Core / Combat / AccuracyModel.cs
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  }