@@ -88,8 +88,6 @@
/** Maximum (effective) level for a stat */
#define MAX_STAT 20

/** Maximum range at which only standard dispersion applies */
#define RANGE_SOFT_CAP 30
/** Maximum range at which ranged attacks can be executed */
#define RANGE_HARD_CAP 60

@@ -4,6 +4,7 @@
#include "advanced_inv.h"
#include "player.h"
#include "damage.h"
#include "dispersion.h"
#include "output.h"
#include "skill.h"
#include "bionics.h"
@@ -1001,9 +1002,20 @@ std::string item::info( bool showtext, std::vector<iteminfo> &info ) const
info.emplace_back( "GUN", space + _( "Maximum range: " ), "<num>", max_gun_range );
}

int aim_mv = g->u.gun_engagement_moves( *mod );
if( aim_mv > 0 ) {
info.emplace_back( "GUN", _( "Maximum aiming time: " ), _( "<num> seconds" ), int( aim_mv / 16.67 ), true, "", true, true );
info.emplace_back( "GUN", _( "Base aim speed: " ), "<num>", g->u.aim_per_move( *mod, MAX_RECOIL ), true, "", true, true );
for( const aim_type type : g->u.get_aim_types( *mod ) ) {
// Nameless aim levels don't get an entry.
if( type.name.empty() ) {
continue;
}
info.emplace_back( "GUN", _( type.name.c_str() ) );
int max_dispersion = g->u.get_weapon_dispersion( *mod ).max();
int range = range_with_even_chance_of_good_hit( max_dispersion + type.threshold );
info.emplace_back( "GUN", _( "Even chance of good hit at range: " ),
_( "<num>" ), range );
int aim_mv = g->u.gun_engagement_moves( *mod, type.threshold );
info.emplace_back( "GUN", _( "Time to reach aim level: " ), _( "<num> seconds" ),
TICKS_TO_SECONDS( aim_mv ), false, "", true, true );
}

info.push_back( iteminfo( "GUN", _( "Damage: " ), "", mod->gun_damage( false ), true, "", false, false ) );
@@ -1168,9 +1180,9 @@ std::string item::info( bool showtext, std::vector<iteminfo> &info ) const
info.push_back( iteminfo( "GUNMOD", _( "Sight dispersion: " ), "",
mod->sight_dispersion, true, "", true, true ) );
}
if( mod->aim_cost >= 0 ) {
info.push_back( iteminfo( "GUNMOD", _( "Aim cost: " ), "",
mod->aim_cost, true, "", true, true ) );
if( mod->aim_speed >= 0 ) {
info.push_back( iteminfo( "GUNMOD", _( "Aim speed: " ), "",
mod->aim_speed, true, "", true, true ) );
}
if( mod->damage != 0 ) {
info.push_back( iteminfo( "GUNMOD", _( "Damage: " ), "", mod->damage, true,
@@ -3984,7 +3996,7 @@ int item::sight_dispersion() const

for( const auto e : gunmods() ) {
const auto mod = e->type->gunmod.get();
if( mod->sight_dispersion < 0 || mod->aim_cost < 0 ) {
if( mod->sight_dispersion < 0 || mod->aim_speed < 0 ) {
continue; // skip gunmods which don't provide a sight
}
res = std::min( res, mod->sight_dispersion );
@@ -814,8 +814,8 @@ void Item_factory::check_definitions() const
if( type->gunmod->location.str().empty() ) {
msg << "gunmod does not specify location" << "\n";
}
if( (type->gunmod->sight_dispersion < 0) != (type->gunmod->aim_cost < 0) ){
msg << "gunmod must have both sight_dispersion and aim_cost set or neither of them set" << "\n";
if( ( type->gunmod->sight_dispersion < 0 ) != ( type->gunmod->aim_speed < 0 ) ){
msg << "gunmod must have both sight_dispersion and aim_speed set or neither of them set" << "\n";
}
}
if( type->mod ) {
@@ -1455,7 +1455,7 @@ void Item_factory::load( islot_gunmod &slot, JsonObject &jo, const std::string &
assign( jo, "location", slot.location );
assign( jo, "dispersion_modifier", slot.dispersion );
assign( jo, "sight_dispersion", slot.sight_dispersion );
assign( jo, "aim_cost", slot.aim_cost, strict, -1 );
assign( jo, "aim_speed", slot.aim_speed, strict, -1 );
assign( jo, "handling_modifier", slot.handling, strict );
assign( jo, "range_modifier", slot.range );
assign( jo, "ammo_effects", slot.ammo_effects, strict );
@@ -436,8 +436,11 @@ struct islot_gunmod : common_ranged_data {
int sight_dispersion = -1;

/**
* For sights (see @ref sight_dispersion), this value affects time cost of aiming. Lower is better. In case of multiple usable sights, the one with lowest aim cost is used. */
int aim_cost = -1;
* For sights (see @ref sight_dispersion), this value affects time cost of aiming.
* Higher is better. In case of multiple usable sights,
* the one with highest aim speed is used.
*/
int aim_speed = -1;

/** Modifies base loudness as provided by the currently loaded ammo */
int loudness = 0;
@@ -856,6 +856,10 @@ tab_direction set_stats(WINDOW *w, player *u, points_left &points)
u->get_hit_base());
mvwprintz( w_description, 1, 0, COL_STAT_BONUS, _("Throwing penalty per target's dodge: +%d"),
u->throw_dispersion_per_dodge( false ) );
if( u->ranged_dex_mod() != 0 ) {
mvwprintz( w_description, 2, 0, COL_STAT_PENALTY, _( "Ranged penalty: -%d" ),
std::abs( u->ranged_dex_mod() ) );
}
fold_and_print(w_description, 4, 0, getmaxx(w_description) - 1, COL_STAT_NEUTRAL,
_("Dexterity also enhances many actions which require finesse."));
break;
@@ -877,13 +881,17 @@ tab_direction set_stats(WINDOW *w, player *u, points_left &points)
break;

case 4:
mvwprintz(w, 9, 2, COL_STAT_ACT, _("Perception:"));
mvwprintz(w, 9, 16, COL_STAT_ACT, "%2d", u->per_max);
if (u->per_max >= HIGH_STAT) {
mvwprintz(w, 3, iSecondColumn, c_ltred, _("Increasing Per further costs 2 points."));
mvwprintz( w, 9, 2, COL_STAT_ACT, _( "Perception:" ) );
mvwprintz( w, 9, 16, COL_STAT_ACT, "%2d", u->per_max );
if( u->per_max >= HIGH_STAT ) {
mvwprintz( w, 3, iSecondColumn, c_ltred, _( "Increasing Per further costs 2 points." ) );
}
if( u->ranged_per_mod() > 0 ) {
mvwprintz( w_description, 0, 0, COL_STAT_PENALTY, _( "Aiming penalty: -%d" ),
u->ranged_per_mod() );
}
fold_and_print(w_description, 2, 0, getmaxx(w_description) - 1, COL_STAT_NEUTRAL,
_("Perception is also used for detecting traps and other things of interest."));
fold_and_print( w_description, 2, 0, getmaxx( w_description ) - 1, COL_STAT_NEUTRAL,
_( "Perception is also used for detecting traps and other things of interest." ) );
break;
}

@@ -1274,9 +1274,8 @@ int npc::confident_gun_mode_range( const item::gun_mode &gun, int at_recoil ) co
return 0;
}

double average_dispersion = get_weapon_dispersion( *( gun.target ), RANGE_SOFT_CAP ).avg() +
(double)at_recoil;
double even_chance_range = 0.5 / average_dispersion;
double average_dispersion = get_weapon_dispersion( *( gun.target ) ).avg() + at_recoil;
double even_chance_range = range_with_even_chance_of_good_hit( average_dispersion );
// 5 round burst equivalent to ~2 individually aimed shots
even_chance_range /= std::max( sqrt( gun.qty / 1.5 ), 1.0 );
double confident_range = even_chance_range * confidence_mult();
@@ -79,7 +79,7 @@
#include <stdlib.h>
#include <limits>

const double MAX_RECOIL = 600;
const double MAX_RECOIL = 3000;

const mtype_id mon_player_blob( "mon_player_blob" );
const mtype_id mon_shadow_snake( "mon_shadow_snake" );
@@ -509,9 +509,8 @@ class player : public Character, public JsonSerializer, public JsonDeserializer
/**
* Returns a weapon's modified dispersion value.
* @param obj Weapon to check dispersion on
* @param range Distance to target against which we're calculating the dispersion
*/
dispersion_sources get_weapon_dispersion( const item &obj, float range ) const;
dispersion_sources get_weapon_dispersion( const item &obj ) const;

/** Returns true if a gun misfires, jams, or has other problems, else returns false */
bool handle_gun_damage( item &firing );
@@ -522,8 +521,8 @@ class player : public Character, public JsonSerializer, public JsonDeserializer
/** Current total maximum recoil penalty from all sources */
double recoil_total() const;

/** How many moves does it take to aim gun to maximum accuracy? */
int gun_engagement_moves( const item &gun ) const;
/** How many moves does it take to aim gun to the target accuracy. */
int gun_engagement_moves( const item &gun, int target = 0, int start = MAX_RECOIL ) const;

/**
* Fires a gun or auxiliary gunmod (ignoring any current mode)
@@ -175,6 +175,8 @@ std::string get_encumbrance_description( const player &p, body_part bp, bool com
s += reload_cost_text( ( eff_encumbrance / 10 ) * 15 );
s += string_format( _( "Dexterity %+.1f when throwing items;\n" ), -( eff_encumbrance / 10.0f ) );
s += melee_cost_text( eff_encumbrance / 2 );
s += "\n";
s += string_format( _( "Reduces aim speed of guns by %.1f." ), p.aim_speed_encumbrance_modifier() );
break;
case bp_leg_l:
case bp_leg_r:
@@ -257,13 +259,15 @@ Strength - 4; Dexterity - 4; Intelligence - 4; Perception - 4" ) );

unsigned maxy = unsigned( TERMY );

unsigned infooffsetytop = 11;
unsigned infooffsetybottom = 15;
std::vector<trait_id> traitslist = get_mutations();

unsigned effect_win_size_y = 1 + unsigned( effect_name.size() );
unsigned trait_win_size_y = 1 + unsigned( traitslist.size() );
unsigned skill_win_size_y = 1 + unsigned( Skill::skill_count() );
unsigned info_win_size_y = 4;

unsigned infooffsetytop = 11;
unsigned infooffsetybottom = infooffsetytop + 1 + info_win_size_y;

if( trait_win_size_y + infooffsetybottom > maxy ) {
trait_win_size_y = maxy - infooffsetybottom;
@@ -292,13 +296,15 @@ Strength - 4; Dexterity - 4; Intelligence - 4; Perception - 4" ) );
WINDOW *w_speed = newwin( 9, 26, 1 + VIEW_OFFSET_Y, 54 + VIEW_OFFSET_X );
WINDOW *w_skills = newwin( skill_win_size_y, 26, infooffsetybottom + VIEW_OFFSET_Y,
0 + VIEW_OFFSET_X );
WINDOW *w_info = newwin( 3, FULL_SCREEN_WIDTH, infooffsetytop + VIEW_OFFSET_Y,
WINDOW *w_info = newwin( info_win_size_y, FULL_SCREEN_WIDTH, infooffsetytop + VIEW_OFFSET_Y,
0 + VIEW_OFFSET_X );

unsigned upper_info_border = 10;
unsigned lower_info_border = 1 + upper_info_border + info_win_size_y;
for( unsigned i = 0; i < unsigned( FULL_SCREEN_WIDTH + 1 ); i++ ) {
//Horizontal line top grid
mvwputch( w_grid_top, 10, i, BORDER_COLOR, LINE_OXOX );
mvwputch( w_grid_top, 14, i, BORDER_COLOR, LINE_OXOX );
mvwputch( w_grid_top, upper_info_border, i, BORDER_COLOR, LINE_OXOX );
mvwputch( w_grid_top, lower_info_border, i, BORDER_COLOR, LINE_OXOX );

//Vertical line top grid
if( i <= infooffsetybottom ) {
@@ -340,12 +346,12 @@ Strength - 4; Dexterity - 4; Intelligence - 4; Perception - 4" ) );
}

//Intersections top grid
mvwputch( w_grid_top, 14, 26, BORDER_COLOR, LINE_OXXX ); // T
mvwputch( w_grid_top, 14, 53, BORDER_COLOR, LINE_OXXX ); // T
mvwputch( w_grid_top, 10, 26, BORDER_COLOR, LINE_XXOX ); // _|_
mvwputch( w_grid_top, 10, 53, BORDER_COLOR, LINE_XXOX ); // _|_
mvwputch( w_grid_top, 10, FULL_SCREEN_WIDTH, BORDER_COLOR, LINE_XOXX ); // -|
mvwputch( w_grid_top, 14, FULL_SCREEN_WIDTH, BORDER_COLOR, LINE_XOXX ); // -|
mvwputch( w_grid_top, lower_info_border, 26, BORDER_COLOR, LINE_OXXX ); // T
mvwputch( w_grid_top, lower_info_border, 53, BORDER_COLOR, LINE_OXXX ); // T
mvwputch( w_grid_top, upper_info_border, 26, BORDER_COLOR, LINE_XXOX ); // _|_
mvwputch( w_grid_top, upper_info_border, 53, BORDER_COLOR, LINE_XXOX ); // _|_
mvwputch( w_grid_top, upper_info_border, FULL_SCREEN_WIDTH, BORDER_COLOR, LINE_XOXX ); // -|
mvwputch( w_grid_top, lower_info_border, FULL_SCREEN_WIDTH, BORDER_COLOR, LINE_XOXX ); // -|
wrefresh( w_grid_top );

mvwputch( w_grid_skill, skill_win_size_y, 26, BORDER_COLOR, LINE_XOOX ); // _|
@@ -684,7 +690,9 @@ Strength - 4; Dexterity - 4; Intelligence - 4; Perception - 4" ) );
mvwprintz( w_stats, 3, 1, h_ltgray, _( "Dexterity:" ) );

mvwprintz( w_stats, 6, 1, c_magenta, _( "Melee to-hit bonus:" ) );
mvwprintz( w_stats, 6, 22, c_magenta, "%+.2lf", get_hit_base() );
mvwprintz( w_stats, 6, 21, c_magenta, "%+.1lf", get_hit_base() );
mvwprintz( w_stats, 7, 1, c_magenta, _( "Ranged penalty:" ) );
mvwprintz( w_stats, 7, 23, c_magenta, "%+3d", -( abs( ranged_dex_mod() ) ) );
mvwprintz( w_stats, 8, 1, c_magenta, _( "Throwing penalty per target's dodge: +%d" ) );
mvwprintz( w_stats, 8, 22, c_magenta, "%3d", throw_dispersion_per_dodge( false ) );

@@ -709,6 +717,10 @@ Strength - 4; Dexterity - 4; Intelligence - 4; Perception - 4" ) );
mvwprintz( w_stats, 5, 1, h_ltgray, _( "Perception:" ) );
mvwprintz( w_stats, 7, 1, c_magenta, _( "Trap detection level:" ) );
mvwprintz( w_stats, 7, 23, c_magenta, "%2d", get_per() );
if( ranged_per_mod() > 0 ) {
mvwprintz( w_stats, 8, 1, c_magenta, _( "Aiming penalty:" ) );
mvwprintz( w_stats, 8, 21, c_magenta, "%+4d", -ranged_per_mod() );
}

fold_and_print( w_info, 0, 1, FULL_SCREEN_WIDTH - 2, c_magenta,
_( "Perception is the most important stat for ranged combat. It's also used for "

Large diffs are not rendered by default.

@@ -65,4 +65,6 @@ class target_handler
const target_callback &on_ammo_change = target_callback() );
};

int range_with_even_chance_of_good_hit( int dispersion );

#endif // RANGED_H
@@ -0,0 +1,43 @@
#include "creature_tracker.h"
#include "game.h"
#include "map.h"
#include "mapdata.h"
#include "monster.h"
#include "player.h"

void wipe_map_terrain()
{
// Remove all the obstacles.
const int mapsize = g->m.getmapsize() * SEEX;
for( int x = 0; x < mapsize; ++x ) {
for( int y = 0; y < mapsize; ++y ) {
g->m.set( x, y, t_grass, f_null );
}
}
for( wrapped_vehicle &veh :
g->m.get_vehicles( tripoint( 0, 0, 0 ),
tripoint( MAPSIZE * SEEX, MAPSIZE * SEEY, 0 ) ) ) {
g->m.destroy_vehicle( veh.v );
}
g->m.build_map_cache( 0, true );
}

void clear_map()
{
wipe_map_terrain();
// Remove any interfering monsters.
while( g->num_zombies() ) {
g->remove_zombie( 0 );
}
// Make sure the player doesn't block the path of the monster being tested.
g->u.setpos( { 0, 0, -2 } );
}

monster &spawn_test_monster( const std::string &monster_type, const tripoint &start )
{
monster temp_monster( mtype_id( monster_type ), start );
// Bypassing game::add_zombie() since it sometimes upgrades the monster instantly.
g->critter_tracker->add( temp_monster );
return *g->critter_tracker->find( 0 );
}

@@ -0,0 +1,12 @@
#ifndef MAP_HELPERS_H
#define MAP_HELPERS_H

#include "enums.h"

#include <string>

void wipe_map_terrain();
void clear_map();
monster &spawn_test_monster( const std::string &monster_type, const tripoint &start );

#endif
@@ -11,47 +11,14 @@
#include "player.h"
#include "vehicle.h"

#include "map_helpers.h"
#include "test_statistics.h"

#include <fstream>
#include <sstream>
#include <string>
#include <vector>

static void wipe_map_terrain()
{
// Remove all the obstacles.
const int mapsize = g->m.getmapsize() * SEEX;
for( int x = 0; x < mapsize; ++x ) {
for( int y = 0; y < mapsize; ++y ) {
g->m.set(x, y, t_grass, f_null);
}
}
for( wrapped_vehicle &veh : g->m.get_vehicles( tripoint( 0, 0, 0 ), tripoint( MAPSIZE * SEEX, MAPSIZE * SEEY, 0 ) ) ) {
g->m.destroy_vehicle( veh.v );
}
g->m.build_map_cache( 0, true );
}

static void clear_map()
{
wipe_map_terrain();
// Remove any interfering monsters.
while( g->num_zombies() ) {
g->remove_zombie( 0 );
}
// Make sure the player doesn't block the path of the monster being tested.
g->u.setpos( { 0, 0, -2 } );
}

static monster &spawn_test_monster( const std::string &monster_type, const tripoint &start )
{
monster temp_monster( mtype_id(monster_type), start);
// Bypassing game::add_zombie() since it sometimes upgrades the monster instantly.
g->critter_tracker->add( temp_monster );
return *g->critter_tracker->find( 0 );
}

static int moves_to_destination( const std::string &monster_type,
const tripoint &start, const tripoint &end )
{
@@ -0,0 +1,326 @@
#include "catch/catch.hpp"

#include "ballistics.h"
#include "dispersion.h"
#include "game.h"
#include "monattack.h"
#include "monster.h"
#include "npc.h"

#include "test_statistics.h"
#include "map_helpers.h"

#include <vector>

template < class T >
std::ostream &operator <<( std::ostream &os, const std::vector<T> &v )
{
os << "[";
for( typename std::vector<T>::const_iterator ii = v.begin(); ii != v.end(); ++ii ) {
os << " " << *ii;
}
os << " ]";
return os;
}

std::ostream &operator<<( std::ostream &stream, const dispersion_sources &sources )
{
if( !sources.normal_sources.empty() ) {
stream << "Normal: " << sources.normal_sources << std::endl;
}
if( !sources.linear_sources.empty() ) {
stream << "Linear: " << sources.linear_sources << std::endl;
}
if( !sources.multipliers.empty() ) {
stream << "Mult: " << sources.multipliers << std::endl;
}
return stream;
}

static void arm_shooter( npc &shooter, std::string gun_type, std::vector<std::string> mods = {} )
{
shooter.remove_weapon();

itype_id gun_id( gun_type );
// Give shooter a loaded gun of the requested type.
item &gun = shooter.i_add( item( gun_id ) );
const itype_id ammo_id = gun.ammo_default();
if( gun.magazine_integral() ) {
item &ammo = shooter.i_add( item( ammo_id, calendar::turn, gun.ammo_capacity() ) );
REQUIRE( gun.is_reloadable_with( ammo_id ) );
REQUIRE( shooter.can_reload( gun, ammo_id ) );
gun.reload( shooter, item_location( shooter, &ammo ), gun.ammo_capacity() );
} else {
const itype_id magazine_id = gun.magazine_default();
item &magazine = shooter.i_add( item( magazine_id ) );
item &ammo = shooter.i_add( item( ammo_id, calendar::turn, magazine.ammo_capacity() ) );
REQUIRE( magazine.is_reloadable_with( ammo_id ) );
REQUIRE( shooter.can_reload( magazine, ammo_id ) );
magazine.reload( shooter, item_location( shooter, &ammo ), magazine.ammo_capacity() );
gun.reload( shooter, item_location( shooter, &magazine ), magazine.ammo_capacity() );
}
for( auto mod : mods ) {
gun.contents.push_back( item( itype_id( mod ) ) );
}
shooter.wield( gun );
}

static void equip_shooter( npc &shooter, std::vector<std::string> apparel )
{
tripoint shooter_pos( 60, 60, 0 );
shooter.setpos( shooter_pos );
shooter.worn.clear();
shooter.inv.clear();
for( const std::string article : apparel ) {
shooter.wear_item( item( article ) );
}
}

std::array<double, 5> accuracy_levels = {{ accuracy_grazing, accuracy_standard, accuracy_goodhit, accuracy_critical, accuracy_headshot }};

static std::array<statistics, 5> firing_test( dispersion_sources dispersion, int range,
std::array<double, 5> thresholds )
{
std::array<statistics, 5> firing_stats;
bool threshold_within_confidence_interval = false;
do {
// On each trip through the loop, grab a sample attack roll and add its results to
// the stat object. Keep sampling until our calculated confidence interval doesn't overlap
// any thresholds we care about. This is a mechanism to limit the number of samples
// we have to accumulate before we declare that the true average is
// either above or below the threshold.
projectile_attack_aim aim = projectile_attack_roll( dispersion, range, 0.5 );
threshold_within_confidence_interval = false;
for( int i = 0; i < accuracy_levels.size(); ++i ) {
firing_stats[i].add( aim.missed_by < accuracy_levels[i] );
if( thresholds[i] == -1 ) {
continue;
}
// If we've accumulated less than 100 or so samples we have a high risk
// of reporting a bad result, so pretend we have high error if samples are low.
if( firing_stats[i].n() < 100 ) {
threshold_within_confidence_interval = true;
continue;
}
double error = firing_stats[i].adj_wald_error();
double avg = firing_stats[i].avg();
double threshold = thresholds[i];
if( avg + error > threshold && avg - error < threshold ) {
threshold_within_confidence_interval = true;
}
}
} while( threshold_within_confidence_interval && firing_stats[0].n() < 10000000 );
return firing_stats;
}

static dispersion_sources get_dispersion( npc &shooter, int aim_time )
{
item &gun = shooter.weapon;
dispersion_sources dispersion = shooter.get_weapon_dispersion( gun );

// The 10 is an arbitrary amount under which NPCs refuse to spend moves on aiming.
shooter.moves = 10 + aim_time;
shooter.recoil = MAX_RECOIL;
// Aim as well as possible within the provided time.
shooter.aim();
if( aim_time > 0 ) {
REQUIRE( shooter.recoil < MAX_RECOIL );
}
dispersion.add_range( shooter.recoil );

return dispersion;
}

static void test_shooting_scenario( npc &shooter, int min_quickdraw_range,
int min_good_range, int max_good_range )
{
{
dispersion_sources dispersion = get_dispersion( shooter, 0 );
std::array<statistics, 5> minimum_stats = firing_test( dispersion, min_quickdraw_range, {{ 0.2, 0.1, -1, -1, -1 }} );
INFO( dispersion );
INFO( "Range: " << min_quickdraw_range );
INFO( "Max aim speed: " << shooter.aim_per_move( shooter.weapon, MAX_RECOIL ) );
INFO( "Min aim speed: " << shooter.aim_per_move( shooter.weapon, shooter.recoil ) );
CAPTURE( minimum_stats[0].n() );
CAPTURE( minimum_stats[0].adj_wald_error() );
CAPTURE( minimum_stats[1].n() );
CAPTURE( minimum_stats[1].adj_wald_error() );
CHECK( minimum_stats[0].avg() < 0.2 );
CHECK( minimum_stats[1].avg() < 0.1 );
}
{
dispersion_sources dispersion = get_dispersion( shooter, 300 );
std::array<statistics, 5> good_stats = firing_test( dispersion, min_good_range, {{ -1, -1, 0.5, -1, -1 }} );
INFO( dispersion );
INFO( "Range: " << min_good_range );
INFO( "Max aim speed: " << shooter.aim_per_move( shooter.weapon, MAX_RECOIL ) );
INFO( "Min aim speed: " << shooter.aim_per_move( shooter.weapon, shooter.recoil ) );
CAPTURE( good_stats[2].n() );
CAPTURE( good_stats[2].adj_wald_error() );
CHECK( good_stats[2].avg() > 0.5 );
}
{
dispersion_sources dispersion = get_dispersion( shooter, 500 );
std::array<statistics, 5> good_stats = firing_test( dispersion, max_good_range, {{ -1, -1, 0.1, -1, -1 }} );
INFO( dispersion );
INFO( "Range: " << max_good_range );
INFO( "Max aim speed: " << shooter.aim_per_move( shooter.weapon, MAX_RECOIL ) );
INFO( "Min aim speed: " << shooter.aim_per_move( shooter.weapon, shooter.recoil ) );
CAPTURE( good_stats[2].n() );
CAPTURE( good_stats[2].adj_wald_error() );
CHECK( good_stats[2].avg() < 0.1 );
}
}

static void test_fast_shooting( npc &shooter, int moves, float hit_rate )
{
const int fast_shooting_range = 3;
const float hit_rate_cap = hit_rate + 0.3;
dispersion_sources dispersion = get_dispersion( shooter, moves );
std::array<statistics, 5> fast_stats = firing_test( dispersion, fast_shooting_range, {{ -1, hit_rate, -1, -1, -1 }} );
std::array<statistics, 5> fast_stats_upper = firing_test( dispersion, fast_shooting_range, {{ -1, hit_rate_cap, -1, -1, -1 }} );
INFO( dispersion );
INFO( "Range: " << fast_shooting_range );
INFO( "Max aim speed: " << shooter.aim_per_move( shooter.weapon, MAX_RECOIL ) );
INFO( "Min aim speed: " << shooter.aim_per_move( shooter.weapon, shooter.recoil ) );
CAPTURE( shooter.weapon.gun_skill().str() );
CAPTURE( shooter.get_skill_level( shooter.weapon.gun_skill() ) );
CAPTURE( shooter.get_dex() );
CAPTURE( to_milliliter( shooter.weapon.volume() ) );
CAPTURE( fast_stats[1].n() );
CAPTURE( fast_stats[1].adj_wald_error() );
CHECK( fast_stats[1].avg() > hit_rate );
CAPTURE( fast_stats_upper[1].n() );
CAPTURE( fast_stats_upper[1].adj_wald_error() );
CHECK( fast_stats_upper[1].avg() < hit_rate_cap );
}

void assert_encumbrance( npc &shooter, int encumbrance )
{
for( body_part bp : bp_aBodyPart ) {
INFO( "Body Part: " << body_part_name( bp ) );
REQUIRE( shooter.encumb( bp ) == encumbrance );
}
}

TEST_CASE( "unskilled_shooter_accuracy", "[ranged] [balance]" )
{
clear_map();
standard_npc shooter( "Shooter", {}, 0, 8, 8, 8, 8 );
equip_shooter( shooter, { "bastsandals", "armguard_chitin", "armor_chitin", "beekeeping_gloves", "fencing_mask" } );
assert_encumbrance( shooter, 10 );

SECTION( "an unskilled shooter with an inaccurate pistol" ) {
arm_shooter( shooter, "glock_19" );
test_shooting_scenario( shooter, 4, 3, 7 );
test_fast_shooting( shooter, 40, 0.3 );
}
SECTION( "an unskilled shooter with an inaccurate smg" ) {
arm_shooter( shooter, "tommygun", { "holo_sight", "tuned_mechanism" } );
test_shooting_scenario( shooter, 4, 4, 10 );
test_fast_shooting( shooter, 80, 0.3 );
}
SECTION( "an unskilled shooter with an inaccurate rifle" ) {
arm_shooter( shooter, "m1918", { "holo_sight", "tuned_mechanism" } );
test_shooting_scenario( shooter, 5, 6, 15 );
test_fast_shooting( shooter, 100, 0.2 );
}
}

TEST_CASE( "competent_shooter_accuracy", "[ranged] [balance]" )
{
clear_map();
standard_npc shooter( "Shooter", {}, 5, 10, 10, 10, 10 );
equip_shooter( shooter, { "cloak_wool", "footrags_wool", "gloves_wraps_fur", "veil_wedding" } );
assert_encumbrance( shooter, 5 );

SECTION( "a skilled shooter with an accurate pistol" ) {
arm_shooter( shooter, "sw_619", { "holo_sight", "pistol_grip", "tuned_mechanism" } );
test_shooting_scenario( shooter, 5, 7, 15 );
test_fast_shooting( shooter, 30, 0.5 );
}
SECTION( "a skilled shooter with an accurate smg" ) {
arm_shooter( shooter, "hk_mp5", { "pistol_scope", "barrel_big", "match_trigger", "adjustable_stock" } );
test_shooting_scenario( shooter, 5, 10, 20 );
test_fast_shooting( shooter, 70, 0.4 );
}
SECTION( "a skilled shooter with an accurate rifle" ) {
arm_shooter( shooter, "ruger_mini", { "rifle_scope", "tuned_mechanism" } );
test_shooting_scenario( shooter, 5, 14, 45 );
test_fast_shooting( shooter, 100, 0.3 );
}
}

TEST_CASE( "expert_shooter_accuracy", "[ranged] [balance]" )
{
clear_map();
standard_npc shooter( "Shooter", {}, 10, 20, 20, 20, 20 );
equip_shooter( shooter, { } );
assert_encumbrance( shooter, 0 );

SECTION( "an expert shooter with an excellent pistol" ) {
arm_shooter( shooter, "sw629", { "holo_sight", "match_trigger" } );
test_shooting_scenario( shooter, 6, 10, 30 );
test_fast_shooting( shooter, 20, 0.6 );
}
SECTION( "an expert shooter with an excellent smg" ) {
arm_shooter( shooter, "ppsh", { "pistol_scope", "barrel_big" } );
test_shooting_scenario( shooter, 6, 20, 50 );
test_fast_shooting( shooter, 60, 0.5 );
}
SECTION( "an expert shooter with an excellent rifle" ) {
arm_shooter( shooter, "browning_blr", { "rifle_scope" } );
test_shooting_scenario( shooter, 6, 30, 150 );
test_fast_shooting( shooter, 100, 0.4 );
}
}

static void range_test( std::array<double, 5> test_thresholds )
{
int index = 0;
for( index = 0; index < accuracy_levels.size(); ++index ) {
if( test_thresholds[index] >= 0 ) {
break;
}
}
// Start at an absurdly high dispersion and count down.
int prev_dispersion = 6000;
for( int r = 1; r <= 60; ++r ) {
int found_dispersion = -1;
// We carry forward prev_dispersion because we never expet the next tier of range to hit the target accuracy level with a lower dispersion.
for( int d = prev_dispersion; d >= 0; --d ) {
std::array<statistics, 5> stats = firing_test( dispersion_sources( d ), r, test_thresholds );
// Switch this from INFO to WARN to debug the scanning process itself.
INFO( "Samples: " << stats[index].n() << " Range: " << r << " Dispersion: " << d <<
" avg hit rate: " << stats[2].avg() );
if( stats[index].avg() > test_thresholds[index] ) {
found_dispersion = d;
prev_dispersion = d;
break;
}
// The intent here is to skip over dispersion values proportionally to how far from converging we are.
// As long as we check several adjacent dispersion values before a hit, we're good.
d -= int( ( test_thresholds[index] - stats[index].avg() ) * 10 ) * 10;
}
if( found_dispersion == -1.0 ) {
WARN( "No matching dispersion found" );
} else {
WARN( "Range: " << r << " Dispersion: " << found_dispersion );
}
}
}

// I added this to find inflection points where accuracy at a particular range crosses a threshold.
// I don't see any assertions we can make about these thresholds offhand.
TEST_CASE( "synthetic_range_test", "[.]" )
{
SECTION( "quickdraw thresholds" ) {
range_test( {{ 0.1, -1, -1, -1, -1 }} );
}
SECTION( "max range thresholds" ) {
range_test( {{ -1, -1, 0.1, -1, -1 }} );
}
SECTION( "good hit thresholds" ) {
range_test( {{ -1, -1, 0.5, -1, -1 }} );
}
}
@@ -28,6 +28,19 @@ class statistics
_min = std::min( _min, new_val );
samples.push_back( new_val );
}
float adj_wald_error() {
// Z-value for 99.9% confidence interval.
constexpr float Z = 3.291;
constexpr float Zsq = Z * Z;
// Implementation of outline from https://measuringu.com/ci-five-steps/
float adj_numerator = ( Zsq / 2 ) + sum();
float adj_denominator = Zsq + n();
float adj_proportion = adj_numerator / adj_denominator;
float a = adj_proportion * ( 1.0 - adj_proportion );
float b = a / adj_denominator;
float c = sqrt( b );
return c * Z;
}
int types() const {
return _types;
}
@@ -81,7 +81,7 @@
"mod_targets": [ "rifle", "crossbow", "launcher" ],
"install_time": 30000,
"sight_dispersion": 0,
"aim_cost": 10,
"aim_speed": 0,
"min_skills": [ [ "gun", 4 ], [ "rifle", 1 ] ],
"flags": [ "ZOOM" ]
},
@@ -1420,7 +1420,7 @@ vehicle_group:vehicles:@=ARRAY,NOWRAP
(^GUNMOD|gunmod_data):install_time
^GUNMOD:gun_data # Actually GENERIC but used only by GUNMOD
(^GUNMOD|gunmod_data):sight_dispersion
(^GUNMOD|gunmod_data):aim_cost
(^GUNMOD|gunmod_data):aim_speed
(^GUNMOD|gunmod_data):range_modifier
(^GUNMOD|gunmod_data):damage_modifier
(^GUNMOD|gunmod_data):dispersion_modifier