Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feral rock throwing target distribution still has room for improvement #80151

Open
oosyrag opened this issue Mar 13, 2025 · 11 comments
Open

Feral rock throwing target distribution still has room for improvement #80151

oosyrag opened this issue Mar 13, 2025 · 11 comments
Labels
[C++] Changes (can be) made in C++. Previously named `Code` Ranged Ranged (firearms, bows, crossbows, throwing), balance, tactics stale Closed for lack of activity, but still valid. <Suggestion / Discussion> Talk it out before implementing

Comments

@oosyrag
Copy link
Contributor

oosyrag commented Mar 13, 2025

Is your feature request related to a problem? Please describe.

Whenever feral rock throwing gets brought up, discussion is usually shut down quickly due to "tests for maintaining the desired outcome" being a thing that exists. After looking at said test (monster_throwing_sanity_test), it seems to test for the average total damage at a given range, with overall hit rate noted. It does not take into account which body part gets hit.

Following that, the next thing that gets mentioned is that data or testing is needed. So I modified the test a bit to track when the torso takes damage, as opposed to when any damage is taken. The result:

Image

At distance 5, nearly 97% (this varied between 95-97% after repeated testing) of hits that did damage (36% overall accuracy) landed on the torso. While it debunks the misconception that ferals can't miss (not even close), and that they always hit the torso (they don't, but it comes really close), the proportion of torso hits is decidedly uncanny. Well above the 72% from #56104, and waaaay above the actual proportion of the torso consists of the body as well (36% from the direct front or back. Note that this number goes down, significantly, at basically any other angle. But lets not worry about that for now). It's exacerbated by the fact that small changes at high percentages are disproportionately perceivable - 75% is 3/4, while 95% is 19/20.

"Aiming for the torso" concentrating hits on the torso doesn't seem to be suitable or apply very well, considering the overall accuracy of 36% of damage dealing hits out of attempts.

As the range gets shorter, the overall accuracy improves (great, as expected), but the proportion of torso shots also increases (also reasonable, just that the base rate was too high to begin with).

My modified test is at master...oosyrag:Cataclysm-DDA:feralthrowtest#diff-1c2e09fb3aaac49cccf9a37b2eff695db19c67a7e6d36caa3f9b406b350db0f6. Maybe I did something wrong or missing something obvious?

Solution you would like.

Damage distributed more reasonably across the body. How to go about implementing this I dunno. Haven't gotten around to thinking about that part yet, especially considering and respecting other "expectations and desired outcomes".

Personal desired result: Less backpack repairs due to rock impacts.

Describe alternatives you have considered.

No response

Additional context

Edit: Removed infographic regarding burn rule of 9s, which is irrelevant here.

@oosyrag oosyrag added the <Suggestion / Discussion> Talk it out before implementing label Mar 13, 2025
@oosyrag
Copy link
Contributor Author

oosyrag commented Mar 13, 2025

Also while rock throwing draws the most attention, possibly having to do with how it is perceived to behave outside the expectations of the average player, this may possibly be indicative of deeper problems with target body part selection expectations across other ranged attack situations as well.

@kevingranade
Copy link
Member

Tl;dr 90%+ hit rate to torso is totally what we expect, the rest of this explains why.

Rule of nines is surface area, the silhouette that we're concerned about in ballistics is different. Surface area overstates the proportion of smaller limbs relative to torso.

The actual process we're modeling is bullet dispersion relative to an aim point, which is aggressively normal, i.e. the vast majority of hits are very close to the target point and the number of hits that land further and further from the target point drop off very rapidly. This means if you are close enough to get reliable hits, nearly all of them will be in the center of the target rather than in the edges, despite the edges having a larger overall surface area.

Putting this another way: Hits are not distributed by surface area, they are distributed based on distance from the target point. The most extreme example here is torso vs legs. Putting both legs together the surface area is about the same as the torso, but the legs are so far from the aim point we are using that only incredibly wild shots are hitting the legs at all, and not many of those.

If you want to change this, you need to present a ballistics oriented argument for what those proportions actually should be. What this roughly looks like is draw concentric circles around the aim point (roughly center of mass or upper middle torso), and for each section, evaluate both what proportion of rounds fall within that area, then evaluate what proportion of that proportion is allocated to each body part.

@oosyrag
Copy link
Contributor Author

oosyrag commented Mar 13, 2025

Thanks for the detailed explanation! I get the gist of it, but I'm still having trouble reconciling that with the .36 overall hit rate. Besides that, there's still a huge difference between even 90% and 95% and 97%.

I'll try to look into this more and see if I can fit the data into an improved model.

@oosyrag
Copy link
Contributor Author

oosyrag commented Mar 14, 2025

It's rough, but is this a better illustration?

Target: 36% overall hit rate, result: 70% torso hit rate
Image

Target: 90% torso hit rate, result: 98% overall hit rate

Image

I can bias the shots more towards the center more, but the overall result will be similar - the percentage of torso hits/all hits just doesn't match with the overall hit rate. And this is only aiming for 90%, 96% is a magnitude more accurate than that, it just doesn't make sense with a 36% overall hit rate.

@anothersimulacrum
Copy link
Member

anothersimulacrum commented Mar 14, 2025

For distance = 5, n = 10000, I see a 90% torso hit rate with 5532 shots hitting, with the following distribution of missed_by values going into the body part selection.

measure value
min 0
max 0.806354627112006
mean 0.43299149085008
median 0.465228355494686
std. Dev 0.199341306916592

data: out.csv

@oosyrag
Copy link
Contributor Author

oosyrag commented Mar 14, 2025

Upgraded my little tool a bit. Can now be biased and draw data points inward towards the target point. The bias controls both the percentage of dots affected and also the percentage lerp from their original position to the target position. With zero bias, points are distributed with a random angle and random distance up to the defined (precision) radius, which already favors/concentrates shots close to the target naturally. At max bias, all points are equal to the target point.

After adding significant bias, with a 90% torso rate (torso hits/total hits), there's no way the overall accuracy (total hits/attempts) is only 36%.

Image

When trying to fit an overall accuracy of 36%, with or without bias, the torso rate isn't going above 70%

Image
Image

In conclusion, a 36% overall accuracy rate should not result in 97% of the hits being torso hits.

Anothersimulacrum, how did you get 90%? When I used the modified monster throwing sanity test, I consistently got over 95%. So if it's supposed to be 90% of hits are torso hits, that's not what is happening in the sanity test. And if 90% of hits being torso hits is desired, the overall hit rate should to be closer to 80% to fit, instead of sub 40%. Then it wouldn't fit the existing hit rate/damage expectations. To preserve the existing damage expectation, the existing hit rate should remain as is, and the proportion of torso hits be reduced to 65%-75% of all hits.

Tool is here - https://oosyrag.github.io/c3ex/

@oosyrag
Copy link
Contributor Author

oosyrag commented Mar 14, 2025

I have a theory regarding the monster throwing sanity test - do limbs deflect at a higher rate than torso? Deflections are counted as misses in that test, as hits are counted when damage is done.

@anothersimulacrum
Copy link
Member

The cause of the discrepancy is that missed_by does not exceed 0.81, when it is expected to be in the range [0, 1].

Also, your hit percentage logic is wrong.

I get the following:

  distance := 5
  Attempts: 10000
  Hits: 3870
  Misses: 6130
  Hit rate: 0.387
  Torso hits: 3708
  % Torso hits: 0.95814 (expected 0.7222?)
  Avg total damage: 2.1747
  Dmg Lower: 2.06207 Dmg Upper: 2.28733

When my logging reports 5414 calls to select_body_part_projectile_attack, 4960 returning torso.

  distance := 2
  Attempts: 10000
  Hits: 9798
  Misses: 202
  Hit rate: 0.9798
  Torso hits: 9744
  % Torso hits: 0.994489 (expected 0.7222?)
  Avg total damage: 7.0411
  Dmg Lower: 6.93976 Dmg Upper: 7.14244

  distance := 3
  Attempts: 10000
  Hits: 7769
  Misses: 2231
  Hit rate: 0.7769
  Torso hits: 7594
  % Torso hits: 0.977475 (expected 0.7222?)
  Avg total damage: 4.6471
  Dmg Lower: 4.52666 Dmg Upper: 4.76754

  distance := 4
  Attempts: 10000
  Hits: 5495
  Misses: 4505
  Hit rate: 0.5495
  Torso hits: 5309
  % Torso hits: 0.966151 (expected 0.7222?)
  Avg total damage: 3.1649
  Dmg Lower: 3.04301 Dmg Upper: 3.28679

data: out.csv

With this patch
diff --git a/src/ballistics.h b/src/ballistics.h
index a1394f43a5..9f62199add 100644
--- a/src/ballistics.h
+++ b/src/ballistics.h
@@ -136,6 +136,7 @@ class targeting_graph
         }
 
         T select( const double range_min, const double range_max, double value ) const {
+            FILE *fp = fopen( "out.csv", "a" );
             // First, find the path we will follow
             // That is, what body parts we will hit with less and less accurate shots
             std::list<const node *> path;
@@ -157,6 +158,7 @@ class targeting_graph
             // Or, if range_min is -0.5, range_max is 1.5, this will put value into the range 0-2
             // And below, we'll have a scale factor of 2 / total_weight
             value -= range_min;
+            fprintf( fp, "%.02f,", value );
 
             // Now, find the total weight along our path
             double total_weight = 0.0;
@@ -169,6 +171,8 @@ class targeting_graph
             double scale_factor = ( range_max - range_min ) / total_weight;
             // Then, just walk along the path
             double accumulated_weight = 0.0;
+            fprintf( fp, "%.02f,%.02f,", total_weight, scale_factor );
+            fclose( fp );
             for( const node *nd : path ) {
                 accumulated_weight += nd->weight * scale_factor;
                 // And quit when we've gone far enough that we can't make it to the next part
@@ -177,9 +181,12 @@ class targeting_graph
                 }
             }
 
+
             // And if we made it all the way here, we (somehow) hit with a very inaccurate shot
             return path.back()->val;
         }
 };
 
+extern bool MYTEST;
+
 #endif // CATA_SRC_BALLISTICS_H
diff --git a/src/creature.cpp b/src/creature.cpp
index 79806c92a1..58789f80a9 100644
--- a/src/creature.cpp
+++ b/src/creature.cpp
@@ -1128,10 +1128,13 @@ struct projectile_attack_results {
     }
 };
 
+bool MYTEST = true;
+
 projectile_attack_results Creature::select_body_part_projectile_attack(
     const projectile &proj, const double goodhit, const bool magic,
     const double missed_by, const weakpoint_attack &attack ) const
 {
+    MYTEST = true;
     projectile_attack_results ret( proj );
     const float crit_multiplier = proj.critical_multiplier;
     const float std_hit_mult = std::sqrt( 2.0 * crit_multiplier );
@@ -1141,6 +1144,7 @@ projectile_attack_results Creature::select_body_part_projectile_attack(
     // 40% true when goodhit = 0, 20% true when goodhit = 0.2
     bool crit_roll = hit_roll * 0.5 < accuracy_critical;
     const monster *mon = as_monster();
+    FILE *fp = fopen( "out.csv", "a" );
     if( mon ) {
         fatal_hit = ret.max_damage * crit_multiplier > get_hp_max();
         ret.wp = mon->type->weakpoints.select_weakpoint( attack );
@@ -1155,6 +1159,7 @@ projectile_attack_results Creature::select_body_part_projectile_attack(
         }
         // Range is -0.5 to 1.5 -> missed_by will be [1, 0], so the rng addition to it
         // will push it to at most 1.5 and at least -0.5
+        fprintf( fp, "%.02f,", hit_value );
         ret.bp_hit = get_anatomy()->select_body_part_projectile_attack( -0.5, 1.5, hit_value );
         crit_mod = get_crit_factor( ret.bp_hit );
         fatal_hit = ret.max_damage * crit_multiplier > get_hp_max( ret.bp_hit );
@@ -1192,6 +1197,10 @@ projectile_attack_results Creature::select_body_part_projectile_attack(
         // ( 0.05, 0.25 ) when goodhit = 0.8, 0.05 when nears 1.0
         ret.damage_mult *= 1.05 - hit_roll;
     }
+
+    fprintf( fp, "%.02f,%s\n", missed_by, ret.bp_hit.id().c_str() );
+    fclose( fp );
+
     add_msg_debug( debugmode::DF_CREATURE, "crit_damage_mult: %f", ret.damage_mult );
     return ret;
 }
diff --git a/tests/monster_attack_test.cpp b/tests/monster_attack_test.cpp
index 22e40ec366..25bbf5862a 100644
--- a/tests/monster_attack_test.cpp
+++ b/tests/monster_attack_test.cpp
@@ -5,6 +5,7 @@
 #include <string>
 #include <vector>
 
+#include "ballistics.h"
 #include "bodypart.h"
 #include "calendar.h"
 #include "cata_catch.h"
@@ -226,6 +227,8 @@ TEST_CASE( "monster_throwing_sanity_test", "[throwing],[balance]" )
         REQUIRE( rl_dist( test_monster.pos_abs(), target->pos_abs() ) <= 5 );
         statistics<int> damage_dealt;
         statistics<bool> hits;
+        statistics<bool> misses;
+        statistics<bool> torso_hits;
         epsilon_threshold threshold{ expected_damage, 2.5 };
         do {
             you.set_all_parts_hp_to_max();
@@ -234,20 +237,34 @@ TEST_CASE( "monster_throwing_sanity_test", "[throwing],[balance]" )
             you.clear_effects();
             you.set_dodges_left( 1 );
             int prev_hp = you.get_hp();
+            int prev_torso_hp = you.get_hp( bodypart_id( "torso" ) );
             // monster shoots the player
+            FILE *fp = fopen( "out.csv", "a" );
+            if( MYTEST ) {
+                fprintf( fp, "%d,", distance );
+                MYTEST = false;
+            }
+            fclose( fp );
             REQUIRE( attack->call( test_monster ) == true );
             // how much damage did it do?
             // Player-centric test in throwing_test.cpp ranges from 2 - 8 damage at point-blank range.
             int current_hp = you.get_hp();
+            int torso_hp = you.get_hp( bodypart_id( "torso" ) );
             hits.add( current_hp < prev_hp );
             damage_dealt.add( prev_hp - current_hp );
+            misses.add( current_hp == prev_hp );
+            torso_hits.add( torso_hp < prev_torso_hp );
             test_monster.ammo[ itype_rock ]++;
-        } while( damage_dealt.n() < 100 || damage_dealt.uncertain_about( threshold ) );
+        } while( damage_dealt.n() < 10000 || damage_dealt.uncertain_about( threshold ) );
         clear_creatures();
         CAPTURE( expected_damage );
         CAPTURE( distance );
-        INFO( "Num hits: " << damage_dealt.n() );
+        INFO( "Attempts: " << damage_dealt.n() );
+        INFO( "Hits: " << hits.sum() );
+        INFO( "Misses: " << misses.sum() );
         INFO( "Hit rate: " << hits.avg() );
+        INFO( "Torso hits: " << torso_hits.sum() );
+        INFO( "% Torso hits: " << torso_hits.sum() / hits.sum() << " (expected 0.7222?)" );
         INFO( "Avg total damage: " << damage_dealt.avg() );
         INFO( "Dmg Lower: " << damage_dealt.lower() << " Dmg Upper: " << damage_dealt.upper() );
         CHECK( damage_dealt.test_threshold( threshold ) );

@anothersimulacrum
Copy link
Member

It is possible target bp selection should use goodhit instead, but that has a lower bound of 0.19, producing the same problem from the opposite direction.

@anothersimulacrum
Copy link
Member

The chain producing that:

aim.dispersion = dispersion.roll();
add_msg_debug( debugmode::DF_BALLISTIC, "Dispersion rolled / max : %f / %f; %f / %f degrees",
aim.dispersion, dispersion.max(), aim.dispersion / 60.0, dispersion.max() / 60.0 );
// an isosceles triangle is formed by the intended and actual target tiles
aim.missed_by_tiles = iso_tangent( range, units::from_arcmin( aim.dispersion ) );
// fraction we missed a monster target by (0.0 = perfect hit, 1.0 = miss)
if( target_size > 0.0 ) {
aim.missed_by = std::min( 1.0, aim.missed_by_tiles / target_size );
} else {
// Special case 0 size targets, just to be safe from 0.0/0.0 NaNs
aim.missed_by = 1.0;
}

projectile_attack_aim aim = projectile_attack_roll( dispersion, range, target_size );
if( target_critter && target_critter->as_character() &&
target_critter->as_character()->has_flag( json_flag_HARDTOHIT ) ) {
projectile_attack_aim lucky_aim = projectile_attack_roll( dispersion, range, target_size );
// if the target's lucky they're more likely to be missed
if( lucky_aim.missed_by > aim.missed_by ) {
aim = lucky_aim;
}
}

// If we shot us a monster...
// TODO: add size effects to accuracy
// If there's a monster in the path of our bullet, and either our aim was true,
// OR it's not the monster we were aiming at and we were lucky enough to hit it
double cur_missed_by = std::min( 1.0, aim.missed_by + spread * std::max( distance - 1, 0 ) );
// unintentional hit on something other than our actual target
// don't re-roll for the actual target, we already decided on a missed_by value for that
// at the start, misses should stay as misses
if( critter != nullptr && tp != target_arg ) {
// Unintentional hit
cur_missed_by = std::max( rng_float( 0.1, 1.5 - aim.missed_by ) /
critter->ranged_target_size(), 0.4 );
}

For any projectile attack that has a chance of missing, if we perform enough trials, then we should see the whole range (perfect hit to near miss) of the hit values we pass to the body part selection. Right now with the bounds we assume we only see the better 80% of that range, not the full range.

We need to somehow ensure the bounds are correct (do a bunch of math and understand the chain of random rolls that get us to the missed_by/goodhit values), and ensure the correct bounds are given to body part selection.

The good news is most of the rolls seem to happen in dispersion, and we don't care about odds - only possible values, which are clamped at various places to be >= 0 and <= 1.

@anothersimulacrum anothersimulacrum added [C++] Changes (can be) made in C++. Previously named `Code` Ranged Ranged (firearms, bows, crossbows, throwing), balance, tactics labels Mar 15, 2025
Copy link
Contributor

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. Please do not bump or comment on this issue unless you are actively working on it. Stale issues, and stale issues that are closed are still considered.

@github-actions github-actions bot added the stale Closed for lack of activity, but still valid. label Mar 30, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[C++] Changes (can be) made in C++. Previously named `Code` Ranged Ranged (firearms, bows, crossbows, throwing), balance, tactics stale Closed for lack of activity, but still valid. <Suggestion / Discussion> Talk it out before implementing
Projects
None yet
Development

No branches or pull requests

3 participants