Skip to content

Commit

Permalink
Add failure chance calculation, adjust catastrophic failure odds (#27)
Browse files Browse the repository at this point in the history
* Display recipe chance of failure

Split the crafting roll function into just providing the parameters for
the normal curve it will be rolling on, and a function doing the actual
roll.
Also, fix some DBZ bugs, and add debug outputs while we're there.

Then, add a function to calculate the chance we'll pass a normal roll
with given center, stddev, and difficulty, and use that to provide the
recipe success chance.
Add this to the crafting GUI.

* Adjust crafting catastrophic failure chances

Fudge the numbers to make catastrophic (item-destroying) failures much
less likely than setback failures.
Also display the chance of a catastrophic failure in the UI.
  • Loading branch information
anothersimulacrum committed Oct 8, 2022
1 parent eec67b1 commit b41fbdc
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 17 deletions.
13 changes: 12 additions & 1 deletion src/character.h
Original file line number Diff line number Diff line change
Expand Up @@ -3106,14 +3106,25 @@ class Character : public Creature, public visitable
const cata::optional<tripoint> &loc );
/** consume components and create an active, in progress craft containing them */
void start_craft( craft_command &command, const cata::optional<tripoint> &loc );

struct craft_roll_data {
float center;
float stddev;
float final_difficulty;
};
/**
* Calculate a value representing the success of the player at crafting the given recipe,
* taking player skill, recipe difficulty, npc helpers, and player mutations into account.
* @param making the recipe for which to calculate
* @return a value >= 0.0 with >= 1.0 representing unequivocal success
*/
double crafting_success_roll( const recipe &making ) const;
float crafting_success_roll( const recipe &making ) const;
float crafting_failure_roll( const recipe &making ) const;
float get_recipe_weighted_skill_average( const recipe &making ) const;
float recipe_success_chance( const recipe &making ) const;
float item_destruction_chance( const recipe &making ) const;
craft_roll_data recipe_success_roll_data( const recipe &making ) const;
craft_roll_data recipe_failure_roll_data( const recipe &making ) const;
void complete_craft( item &craft, const cata::optional<tripoint> &loc );
/**
* Check if the player meets the requirements to continue the in progress craft and if
Expand Down
137 changes: 121 additions & 16 deletions src/crafting.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1001,23 +1001,24 @@ bool Character::craft_proficiency_gain( const item &craft, const time_duration &

float Character::get_recipe_weighted_skill_average( const recipe &making ) const
{
if( has_trait( trait_DEBUG_CNF ) ) {
return 1.0;
}
int secondary_skill_total = 0;
int secondary_difficulty = 0;
for( const auto &count_secondaries : making.required_skills ) {
for( const std::pair<const skill_id, int> &count_secondaries : making.required_skills ) {
// the difficulty of each secondary skill, count_secondaries.second, adds weight:
// skills required at a higher level count more.
secondary_skill_total += get_skill_level( count_secondaries.first ) * count_secondaries.second;
secondary_difficulty += count_secondaries.second;
}
add_msg_debug( debugmode::DF_CRAFTING,
"For craft %s, has %d secondary skills with difficulty sum %d", making.ident().str(),
secondary_skill_total, secondary_difficulty );
// The primary required skill counts extra compared to the secondary skills, before factoring in the
// weight added by the required level.
const float weighted_skill_average =
( ( 2.0f * making.difficulty * get_skill_level( making.skill_used ) ) + secondary_skill_total ) /
std::max( ( 2.0f * making.difficulty + secondary_difficulty ), 1.0f );
add_msg_debug( debugmode::DF_CHARACTER, "Weighted skill average: %f", weighted_skill_average );
// No DBZ
std::max( 1.f, ( 2.0f * making.difficulty + secondary_difficulty ) );
add_msg_debug( debugmode::DF_CRAFTING, "Weighted skill average: %g", weighted_skill_average );

float total_skill_modifiers = 0.0f;

Expand Down Expand Up @@ -1048,6 +1049,8 @@ float Character::get_recipe_weighted_skill_average( const recipe &making ) const
// For now let's just use Intelligence. For the average intelligence of 8, give +2. Inc/dec by 0.25 per stat point.
// This ensures that at parity, where skill = difficulty, you have a roughly 85% chance of success at average intelligence.
total_skill_modifiers += int_cur / 4.0f;
add_msg_debug( debugmode::DF_CRAFTING, "Total skill modifiers: %g (+%g from int)",
total_skill_modifiers, int_cur / 4.f );

// Missing proficiencies penalize skill level
// At the time of writing this is currently called a fail multiplier.
Expand All @@ -1058,11 +1061,12 @@ float Character::get_recipe_weighted_skill_average( const recipe &making ) const
}
}

add_msg_debug( debugmode::DF_CHARACTER, "Total skill modifiers: %f", total_skill_modifiers );
add_msg_debug( debugmode::DF_CHARACTER, "Total skill modifiers after proficiencies: %g",
total_skill_modifiers );
return weighted_skill_average + total_skill_modifiers;
}

double Character::crafting_success_roll( const recipe &making ) const
Character::craft_roll_data Character::recipe_success_roll_data( const recipe &making ) const
{
// We're going to use a sqrt( sum of squares ) method here to give diminishing returns for more low level helpers.
float player_weighted_skill_average = get_recipe_weighted_skill_average( making );
Expand Down Expand Up @@ -1111,7 +1115,7 @@ double Character::crafting_success_roll( const recipe &making ) const

int secondary_difficulty = 0;
int secondary_level_count = 0;
for( const auto &count_secondaries : making.required_skills ) {
for( const std::pair<const skill_id, int> &count_secondaries : making.required_skills ) {
secondary_level_count += count_secondaries.second;
secondary_difficulty += std::pow( count_secondaries.second, 2 );
}
Expand All @@ -1122,7 +1126,8 @@ double Character::crafting_success_roll( const recipe &making ) const
// and then divided out again makes it a bit messy. Sorry, less mathy friends.
const float final_difficulty =
( 2.0f * making.difficulty * making.difficulty + 1.0f * secondary_difficulty ) /
( 2.0f * making.difficulty + 1.0f * secondary_level_count );
// NO DBZ
std::max( 1.f, ( 2.0f * making.difficulty + 1.0f * secondary_level_count ) );
add_msg_debug( debugmode::DF_CHARACTER, "Final craft difficulty: %f", final_difficulty );

// in the future we might want to make the standard deviation vary depending on some feature of the recipe.
Expand All @@ -1138,12 +1143,105 @@ double Character::crafting_success_roll( const recipe &making ) const
// This means that luck plays less of a role the more overqualified you are.
crafting_stddev -= std::min( ( weighted_skill_average - final_difficulty ) / 4, 1.0f );
}
float craft_roll = std::max( normal_roll( weighted_skill_average, crafting_stddev ), 0.0 );

add_msg_debug( debugmode::DF_CHARACTER, "Crafting skill roll: %f", craft_roll );
// Let's just be careful, I don't want to touch a negative stddev
crafting_stddev = std::max( crafting_stddev, 0.f );

craft_roll_data ret;
ret.center = weighted_skill_average;
ret.stddev = crafting_stddev;
ret.final_difficulty = final_difficulty + 1;
if( has_trait( trait_DEBUG_CNF ) ) {
ret.center = 2.f;
ret.stddev = 0.f;
ret.final_difficulty = 0.f;
}
return ret;
}

Character::craft_roll_data Character::recipe_failure_roll_data( const recipe &making ) const
{
craft_roll_data data = recipe_success_roll_data( making );
// Fund the numbers for the outcomes we want
data.final_difficulty -= 1;
data.final_difficulty *= 0.25;
data.stddev *= 0.5;
return data;
}

float Character::crafting_success_roll( const recipe &making ) const
{
craft_roll_data data = recipe_success_roll_data( making );
float craft_roll = std::max( normal_roll( data.center, data.stddev ), 0.0 );

add_msg_debug( debugmode::DF_CHARACTER, "Crafting skill roll: %f, final difficulty %g", craft_roll,
data.final_difficulty );

return std::max( craft_roll - data.final_difficulty, 0.0f );
}

float Character::crafting_failure_roll( const recipe &making ) const
{
craft_roll_data data = recipe_failure_roll_data( making );
float craft_roll = std::max( normal_roll( data.center, data.stddev ), 0.0 );

add_msg_debug( debugmode::DF_CHARACTER, "Crafting skill roll: %f, final difficulty %g", craft_roll,
data.final_difficulty );

return std::max( craft_roll, 0.0f );
}

// Returns the area under a curve with provided standard deviation and center
// from difficulty to positive to infinity. That is, the chance that a normal roll on
// said curve will return a value of difficulty or greater.
static float normal_roll_chance( float center, float stddev, float difficulty )
{
cata_assert( stddev >= 0.f );
// We're going to be using them a lot, so let's name our variables.
// M = the given "center" of the curve
// S = the given standard deviation of the curve
// A = the difficulty
// So, the equation of the normal curve is...
// y = (1.f/(S*std::sqrt(2 * M_PI))) * exp(-(std::pow(x - M, 2))/(2 * std::pow(S, 2)))
// Thanks to wolfram alpha, we know the integral of that from A to B to be
// 0.5 * (erf((M-A)/(std::sqrt(2) * S)) - erf((M-B)/(std::sqrt(2) * S)))
// And since we know B to be infinity, we can simplify that to
// 0.5 * (erfc((A-m)/(std::sqrt(2)* S))+sgn(S)-1) (as long as S != 0)
// Wait a second, what are erf, erfc and sgn?
// Oh, those are the error function, complementary error function, and sign function
// Luckily, erf() is provided to us in math.h, and erfc is just 1 - erf
// Sign is pretty obvious x > 0 ? x == 0 ? 0 : 1 : -1;
// Since we know S will always be > 0, that term vanishes.

// With no standard deviation, we will always return center
if( stddev == 0.f ) {
return ( center > difficulty ) ? 1.f : 0.f;
}

float numerator = difficulty - center;
float denominator = std::sqrt( 2 ) * stddev;
float compl_erf = 1.f - std::erf( numerator / denominator );
return 0.5 * compl_erf;
}

float Character::recipe_success_chance( const recipe &making ) const
{
// We calculate the failure chance of a recipe by performing a normal roll with a given
// standard deviation and center, then subtracting a "final difficulty" score from that.
// If that result is above 1, there is no chance of failure.
craft_roll_data data = recipe_success_roll_data( making );

return normal_roll_chance( data.center, data.stddev, 1.f + data.final_difficulty );
}

float Character::item_destruction_chance( const recipe &making ) const
{
// If a normal roll with these parameters rolls over 1, we will not have a catastrophic failure
// If we roll under one, we will
craft_roll_data data = recipe_failure_roll_data( making );

// TK: check all calls to crafting_success_roll, make sure they fit with the outputs this gives.
return std::max( craft_roll - final_difficulty + 1, 0.0f );
// normal_roll_chance returns the chance that we roll over, we want the chance we roll under
return 1.f - normal_roll_chance( data.center, data.stddev, 1.f + data.final_difficulty );
}

int item::get_next_failure_point() const
Expand All @@ -1163,9 +1261,16 @@ void item::set_next_failure_point( const Character &crafter )
}

const int percent = 10000000;
const int failure_point_delta = crafter.crafting_success_roll( get_making() ) * percent;
const float roll = crafter.crafting_success_roll( get_making() );
const int failure_point_delta = roll * percent;

craft_data_->next_failure_point = item_counter + failure_point_delta;
// Accurately prints if we multiply by 100
const float percent_fp = static_cast<float>( percent ) * 100;
add_msg_debug( debugmode::DF_CRAFTING,
"Set failure point: chose +%g%% for %s, will occur when progress hits %g%% (roll %g)",
failure_point_delta / percent_fp, get_making().ident().str(),
craft_data_->next_failure_point / percent_fp, roll );
}

static void destroy_random_component( item &craft, const Character &crafter )
Expand All @@ -1189,7 +1294,7 @@ bool item::handle_craft_failure( Character &crafter )
return false;
}

const double success_roll = crafter.crafting_success_roll( get_making() );
const double success_roll = crafter.crafting_failure_roll( get_making() );
const int starting_components = this->components.size();
// Destroy at most 75% of the components, always a chance of losing 1 though
const size_t max_destroyed = std::max<size_t>( 1, components.size() * 3 / 4 );
Expand Down
38 changes: 38 additions & 0 deletions src/crafting_gui.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,41 @@ struct availability {
};
} // namespace

static std::string craft_success_chance_string( const recipe &recp, const Character &guy )
{
float chance = 100.f * ( 1.f - guy.recipe_success_chance( recp ) );
std::string color;
if( chance > 75 ) {
color = "yellow";
} else if( chance > 50 ) {
color = "light_gray";
} else if( chance > 25 ) {
color = "green";
} else {
color = "cyan";
}

return string_format( _( "Minor Failure Chance: <color_%s>%2.2f</color>" ), color, chance );
}

static std::string cata_fail_chance_string( const recipe &recp, const Character &guy )
{
float chance = 100.f * guy.item_destruction_chance( recp );
std::string color;
if( chance > 50 ) {
color = "i_red";
} else if( chance > 20 ) {
color = "red";
} else if( chance > 5 ) {
color = "yellow";
} else {
color = "light_gray";
}

return string_format( _( "Catastrophic Failure Chance: <color_%s>%2.2f</color>" ), color, chance );
}


static std::vector<std::string> recipe_info(
const recipe &recp,
const availability &avail,
Expand Down Expand Up @@ -311,6 +346,9 @@ static std::vector<std::string> recipe_info(
oss << string_format( _( "Proficiencies Missing: %s\n" ), missing_profs );
}

oss << craft_success_chance_string( recp, guy ) << "\n";
oss << cata_fail_chance_string( recp, guy ) << "\n";

if( !recp.is_nested() ) {
const int expected_turns = guy.expected_time_to_craft( recp, batch_size )
/ to_moves<int>( 1_turns );
Expand Down
1 change: 1 addition & 0 deletions src/debug.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ std::string filter_name( debug_filter value )
case DF_CHARACTER: return "DF_CHARACTER";
case DF_CHAR_CALORIES: return "DF_CHAR_CALORIES";
case DF_CHAR_HEALTH: return "DF_CHAR_HEALTH";
case DF_CRAFTING: return "DF_CRAFTING";
case DF_CREATURE: return "DF_CREATURE";
case DF_EFFECT: return "DF_EFFECT";
case DF_EXPLOSION: return "DF_EXPLOSION";
Expand Down
1 change: 1 addition & 0 deletions src/debug.h
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ enum debug_filter : int {
DF_CHARACTER, // character generic
DF_CHAR_CALORIES, // character stomach and calories
DF_CHAR_HEALTH, // character health related
DF_CRAFTING, // Crafting everything
DF_CREATURE, // creature generic
DF_EFFECT, // effects generic
DF_EXPLOSION, // explosion generic
Expand Down

0 comments on commit b41fbdc

Please sign in to comment.