From ba75c03d9acfc1f5df5ce378ad27c55906614f49 Mon Sep 17 00:00:00 2001 From: George Karfakis Date: Fri, 2 Oct 2020 10:18:15 +0100 Subject: [PATCH] PR #2: Replacing the CAL/kg row of the (E)at menu with a more meaningful Satiety row. (#44490) --- data/mods/TEST_DATA/items.json | 25 ++++++++++ src/character.h | 4 ++ src/consumption.cpp | 45 ++++++++++++++---- src/game_inventory.cpp | 35 +++++++++----- src/output.cpp | 17 +++++++ src/output.h | 1 + tests/comestible_test.cpp | 85 ++++++++++++++++++++++++++++++++++ 7 files changed, 192 insertions(+), 20 deletions(-) diff --git a/data/mods/TEST_DATA/items.json b/data/mods/TEST_DATA/items.json index 7bdbbf430ceb0..e7ff2f1428d77 100644 --- a/data/mods/TEST_DATA/items.json +++ b/data/mods/TEST_DATA/items.json @@ -656,6 +656,31 @@ "smoking_result": "dry_fruit", "vitamins": [ [ "vitA", 2 ], [ "vitC", 14 ], [ "iron", 1 ] ] }, + { + "id": "test_egg", + "type": "COMESTIBLE", + "comestible_type": "FOOD", + "category": "food", + "name": { "str": "test egg" }, + "description": "Test egg, very much like a bird egg, but a little too perfect. Does not need to be cooked.", + "volume": "50 ml", + "weight": "40 g", + "color": "brown", + "spoils_in": "7 days", + "symbol": "o", + "quench": 4, + "healthy": 1, + "calories": 80, + "price": 44, + "price_postapoc": 50, + "material": [ "egg" ], + "fun": -8, + "//": "Omitting the RAW flag so calories will be taken at face value.", + "flags": [ "FREEZERBURN" ], + "rot_spawn": "GROUP_EGG_BIRD_WILD", + "vitamins": [ [ "vitA", 9 ], [ "calcium", 3 ], [ "iron", 4 ], [ "vitB", 21 ] ], + "rot_spawn_chance": 50 + }, { "id": "test_jug_plastic", "type": "GENERIC", diff --git a/src/character.h b/src/character.h index 7f13634a72696..defe11d07bf14 100644 --- a/src/character.h +++ b/src/character.h @@ -2255,6 +2255,10 @@ class Character : public Creature, public visitable void modify_addiction( const islot_comestible &comest ); /** Used to apply health modifications from food and medication **/ void modify_health( const islot_comestible &comest ); + /** Used to compute how filling a food is.*/ + double compute_effective_food_volume_ratio( const item &food ) const; + /** Used to to display how filling a food is. */ + int compute_calories_per_effective_volume( const item &food ) const; /** Handles the effects of consuming an item */ bool consume_effects( item &food ); /** Check character's capability of consumption overall */ diff --git a/src/consumption.cpp b/src/consumption.cpp index 7a1a446d69b3a..ee64295e168ef 100644 --- a/src/consumption.cpp +++ b/src/consumption.cpp @@ -1254,6 +1254,42 @@ void Character::modify_morale( item &food, const int nutr ) } } + +// Used when determining stomach fullness from eating. +double Character::compute_effective_food_volume_ratio( const item &food ) const +{ + const nutrients food_nutrients = compute_effective_nutrients( food ); + units::mass food_weight = ( food.weight() / food.count() ); + double ratio = 1.0f; + if( units::to_gram( food_weight ) != 0 ) { + ratio = std::max( static_cast( food_nutrients.kcal ) / units::to_gram( food_weight ), 1.0 ); + if( ratio > 3.0f ) { + ratio = std::sqrt( 3 * ratio ); + } + } + return ratio; +} + +// Used when displaying effective food satiation values. +int Character::compute_calories_per_effective_volume( const item &food ) const +{ + /* Understanding how Calories Per Effective Volume are calculated requires a dive into the + stomach fullness source code. Look at issue #44365*/ + const nutrients nutr = compute_effective_nutrients( food ); + const int kcalories = nutr.kcal; + units::volume water_vol = ( food.type->comestible->quench > 0 ) ? food.type->comestible->quench * + 5_ml : 0_ml; + // Water volume is ignored. + units::volume food_vol = food.volume() - water_vol * food.count(); + // Divide by 1000 to convert to L. Final quantity is essentially dimensionless, so unit of measurement does not matter. + const double converted_volume = round_up( ( static_cast( food_vol.value() ) / food.count() ) + * 0.001, 2 ); + const double energy_density_ratio = compute_effective_food_volume_ratio( food ); + const double effective_volume = converted_volume * energy_density_ratio; + return std::round( kcalories / effective_volume ); +} + + bool Character::consume_effects( item &food ) { if( !food.is_comestible() ) { @@ -1378,14 +1414,7 @@ bool Character::consume_effects( item &food ) 5_ml : 0_ml; units::volume food_vol = food.base_volume() - water_vol; units::mass food_weight = ( food.weight() / food.count() ); - double ratio = 1.0f; - if( units::to_gram( food_weight ) != 0 ) { - ratio = std::max( static_cast( food_nutrients.kcal ) / units::to_gram( food_weight ), 1.0 ); - if( ratio > 3.0f ) { - ratio = std::sqrt( 3 * ratio ); - } - } - + const double ratio = compute_effective_food_volume_ratio( food ); food_summary ingested{ water_vol, food_vol * ratio, diff --git a/src/game_inventory.cpp b/src/game_inventory.cpp index d33dbf2940e67..d6e42255f7892 100644 --- a/src/game_inventory.cpp +++ b/src/game_inventory.cpp @@ -487,6 +487,7 @@ item_location game_menus::inv::disassemble( Character &p ) _( "You don't have any items you could disassemble." ) ); } + class comestible_inventory_preset : public inventory_selector_preset { public: @@ -534,22 +535,32 @@ class comestible_inventory_preset : public inventory_selector_preset return string_format( _( "%.2f%s" ), converted_volume, volume_units_abbr() ); }, _( "VOLUME" ) ); - append_cell( [&p]( const item_location & loc ) { + // Title of this cell. Defined here in order to preserve proper padding and alignment of values in the lambda. + const std::string this_cell_title = _( "SATIETY" ); + append_cell( [&p, this_cell_title]( const item_location & loc ) { const item &it = *loc; - const int charges = std::max( it.charges, 1 ); - const double converted_weight = convert_weight( it.weight() / charges ); - if( converted_weight == 0 ) { - return std::string( "---" ); + // Quit prematurely if the item is not food. + if( !it.type->comestible ) { + return std::string(); } - const nutrients nutr = p.compute_effective_nutrients( *loc ); - const int kcalories = nutr.kcal; - // Experimental: if calories are 0 (medicine, batteries etc), don't display anything. - if( kcalories == 0 ) { + const int calories_per_effective_volume = p.compute_calories_per_effective_volume( it ); + // Show empty cell instead of 0. + if( calories_per_effective_volume == 0 ) { return std::string(); } - const int calpergr = int( std::round( kcalories / converted_weight ) ); - return string_format( _( "%d" ), calpergr ); - }, _( "CAL/kg" ) ); + /* This is for screen readers. I will make a PR to discuss what these prerequisites could be - + bio_digestion, selfaware, high cooking skill etc*/ + constexpr bool ARBITRARY_PREREQUISITES_TO_BE_DETERMINED_IN_THE_FUTURE = false; + if( ARBITRARY_PREREQUISITES_TO_BE_DETERMINED_IN_THE_FUTURE ) { + return string_format( "%d", calories_per_effective_volume ); + } + std::string result = satiety_bar( calories_per_effective_volume ); + // if this_cell_title is larger than 5 characters, pad to match its length, preserving alignment. + if( utf8_width( this_cell_title ) > 5 ) { + result += std::string( utf8_width( this_cell_title ) - 5, ' ' ); + } + return result; + }, _( this_cell_title ) ); Character &player_character = get_player_character(); append_cell( [&player_character]( const item_location & loc ) { diff --git a/src/output.cpp b/src/output.cpp index 84c93f564b8e6..3372dcf45a395 100644 --- a/src/output.cpp +++ b/src/output.cpp @@ -1965,6 +1965,23 @@ void insert_table( const catacurses::window &w, int pad, int line, int columns, wattroff( w, FG ); } + +std::string satiety_bar( const int calpereffv ) +{ + // Arbitrary max value we will cap our vague display to. Will be lower than the actual max value, but scaling fixes that. + constexpr int max_cal_per_effective_vol = 1500; + //Scaling the values. + const int scaled_max = std::sqrt( max_cal_per_effective_vol ) / 4; + const int scaled_cal = std::sqrt( calpereffv ) / 4; + const std::pair nourishment_bar = get_bar( + scaled_cal, scaled_max, 5, true ); + // Colorize the bar. + std::string result = colorize( nourishment_bar.first, nourishment_bar.second ); + // Pad to 5 characters with dots. + result += std::string( 5 - nourishment_bar.first.length(), '.' ); + return result; +} + scrollingcombattext::cSCT::cSCT( const point &p_pos, const direction p_oDir, const std::string &p_sText, const game_message_type p_gmt, const std::string &p_sText2, const game_message_type p_gmt2, diff --git a/src/output.h b/src/output.h index 57442b3dd01bd..33418cffe9dc6 100644 --- a/src/output.h +++ b/src/output.h @@ -328,6 +328,7 @@ int right_print( const catacurses::window &w, int line, int right_indent, void insert_table( const catacurses::window &w, int pad, int line, int columns, const nc_color &FG, const std::string ÷r, bool r_align, const std::vector &data ); +std::string satiety_bar( int calpereffv ); void scrollable_text( const std::function &init_window, const std::string &title, const std::string &text ); std::string name_and_value( const std::string &name, int value, int field_width ); diff --git a/tests/comestible_test.cpp b/tests/comestible_test.cpp index d523d5cc78cd4..519bde7b34360 100644 --- a/tests/comestible_test.cpp +++ b/tests/comestible_test.cpp @@ -10,6 +10,7 @@ #include "item.h" #include "item_contents.h" #include "itype.h" +#include "output.h" #include "recipe.h" #include "recipe_dictionary.h" #include "requirements.h" @@ -178,3 +179,87 @@ TEST_CASE( "cooked_veggies_get_correct_calorie_prediction", "[recipe]" ) CHECK( default_nutrition.kcal == predicted_nutrition.first.kcal ); CHECK( default_nutrition.kcal == predicted_nutrition.second.kcal ); } + +// The Character::compute_effective_food_volume_ratio function returns a floating-point ratio +// used as a multiplier for the food volume when it is eaten, based on the energy density +// (kcal/gram) of the food, as follows: +// +// - low-energy food (0.0 < kcal/gram < 1.0) returns 1.0 +// - medium-energy food (1.0 < kcal/gram < 3.0) returns (kcal/gram) +// - high-energy food (3.0 < kcal/gram) returns sqrt( 3 * kcal/gram ) +// +// The Character::compute_calories_per_effective_volume function returns a dimensionless integer +// representing the "satiety" of the food, with higher numbers being more calorie-dense, and lower +// numbers being less so. +// +TEST_CASE( "effective food volume and satiety", "[character][food][satiety]" ) +{ + const Character &u = get_player_character(); + double expect_ratio; + + // Apple: 95 kcal / 200 g (1 serving) + const item apple( "test_apple" ); + const nutrients apple_nutr = u.compute_effective_nutrients( apple ); + REQUIRE( apple.count() == 1 ); + REQUIRE( apple.weight() == 200_gram ); + REQUIRE( apple.volume() == 250_ml ); + REQUIRE( apple_nutr.kcal == 95 ); + // If kcal per gram < 1.0, return 1.0 + CHECK( u.compute_effective_food_volume_ratio( apple ) == Approx( 1.0f ).margin( 0.01f ) ); + CHECK( u.compute_calories_per_effective_volume( apple ) == 396 ); + // NOLINTNEXTLINE(cata-text-style): verbatim ellipses necessary for validation + CHECK( satiety_bar( 396 ) == "||..." ); + + // Egg: 80 kcal / 40 g (1 serving) + const item egg( "test_egg" ); + const nutrients egg_nutr = u.compute_effective_nutrients( egg ); + REQUIRE( egg.count() == 1 ); + REQUIRE( egg.weight() == 40_gram ); + REQUIRE( egg.volume() == 50_ml ); + REQUIRE( egg_nutr.kcal == 80 ); + // If kcal per gram > 1.0 but less than 3.0, return ( kcal / gram ) + CHECK( u.compute_effective_food_volume_ratio( egg ) == Approx( 2.0f ).margin( 0.01f ) ); + CHECK( u.compute_calories_per_effective_volume( egg ) == 1333 ); + CHECK( satiety_bar( 1333 ) == "|||||" ); + + // Pine nuts: 202 kcal / 30 g (4 servings) + const item nuts( "test_pine_nuts" ); + const nutrients nuts_nutr = u.compute_effective_nutrients( nuts ); + // If food count > 1, total weight is divided by count before computing kcal/gram + REQUIRE( nuts.count() == 4 ); + REQUIRE( nuts.weight() == 120_gram ); + REQUIRE( nuts.volume() == 250_ml ); + REQUIRE( nuts_nutr.kcal == 202 ); + // If kcal per gram > 3.0, return sqrt( 3 * kcal / gram ) + expect_ratio = std::sqrt( 3.0f * 202 / 30 ); + CHECK( u.compute_effective_food_volume_ratio( nuts ) == Approx( expect_ratio ).margin( 0.01f ) ); + CHECK( u.compute_calories_per_effective_volume( nuts ) == 642 ); + CHECK( satiety_bar( 642 ) == "|||.." ); +} + +// satiety_bar returns a colorized string indicating a satiety level, similar to hit point bars +// where "....." is minimum (~ 0) and "|||||" is maximum (~ 1500) +// +TEST_CASE( "food satiety bar", "[character][food][satiety]" ) +{ + // NOLINTNEXTLINE(cata-text-style): verbatim ellipses necessary for validation + CHECK( satiety_bar( 0 ) == "....." ); + // NOLINTNEXTLINE(cata-text-style): verbatim ellipses necessary for validation + CHECK( satiety_bar( 100 ) == "|...." ); + // NOLINTNEXTLINE(cata-text-style): verbatim ellipses necessary for validation + CHECK( satiety_bar( 200 ) == "|\\..." ); + // NOLINTNEXTLINE(cata-text-style): verbatim ellipses necessary for validation + CHECK( satiety_bar( 300 ) == "||..." ); + CHECK( satiety_bar( 400 ) == "||\\.." ); + CHECK( satiety_bar( 500 ) == "||\\.." ); + CHECK( satiety_bar( 600 ) == "|||.." ); + CHECK( satiety_bar( 700 ) == "|||.." ); + CHECK( satiety_bar( 800 ) == "|||\\." ); + CHECK( satiety_bar( 900 ) == "|||\\." ); + CHECK( satiety_bar( 1000 ) == "|||\\." ); + CHECK( satiety_bar( 1100 ) == "||||." ); + CHECK( satiety_bar( 1200 ) == "||||." ); + CHECK( satiety_bar( 1300 ) == "|||||" ); + CHECK( satiety_bar( 1400 ) == "|||||" ); + CHECK( satiety_bar( 1500 ) == "|||||" ); +}