diff --git a/src/cached_item_options.cpp b/src/cached_item_options.cpp new file mode 100644 index 000000000000..375f8e2d3746 --- /dev/null +++ b/src/cached_item_options.cpp @@ -0,0 +1,5 @@ +#include "cached_item_options.h" + +merge_comestible_t merge_comestible_mode = merge_comestible_t::merge_legacy; + +float similarity_threshold; diff --git a/src/cached_item_options.h b/src/cached_item_options.h new file mode 100644 index 000000000000..93e5fb96bc40 --- /dev/null +++ b/src/cached_item_options.h @@ -0,0 +1,24 @@ +#pragma once +#ifndef CATA_SRC_CACHED_ITEM_OPTIONS_H +#define CATA_SRC_CACHED_ITEM_OPTIONS_H + +enum class merge_comestible_t { + merge_legacy, + merge_liquid, + merge_all, +}; + +/** + * Merge similar comestibles. Legacy: default behavior. Liquid: Merge only liquid comestibles. All: Merge all comestibles. + */ +extern merge_comestible_t merge_comestible_mode; + +/** + * Limit maximum allowed staleness difference when merging comestibles. + * The lower the value, the more similar the items must be to merge. + * 0.0: Only merge identical items. + * 1.0: Merge comestibles regardless of its freshness. + */ +extern float similarity_threshold; + +#endif // CATA_SRC_CACHED_ITEM_OPTIONS_H diff --git a/src/item.cpp b/src/item.cpp index d3239d3883be..aeb1fe18ef23 100644 --- a/src/item.cpp +++ b/src/item.cpp @@ -25,6 +25,7 @@ #include "bodypart.h" #include "cata_utility.h" #include "catacharset.h" +#include "cached_item_options.h" #include "character.h" #include "character_id.h" #include "character_encumbrance.h" @@ -1003,13 +1004,28 @@ bool item::stacks_with( const item &rhs, bool check_components, bool skip_type_c if( goes_bad() && rhs.goes_bad() ) { // Stack items that fall into the same "bucket" of freshness. // Distant buckets are larger than near ones. - std::pair my_clipped_time_to_rot = - clipped_time( get_shelf_life() - rot ); - std::pair other_clipped_time_to_rot = - clipped_time( rhs.get_shelf_life() - rhs.rot ); - if( my_clipped_time_to_rot != other_clipped_time_to_rot ) { - return false; + + switch( merge_comestible_mode ) { + case merge_comestible_t::merge_legacy: { + std::pair my_clipped_time_to_rot = + clipped_time( get_shelf_life() - rot ); + std::pair other_clipped_time_to_rot = + clipped_time( rhs.get_shelf_life() - rhs.rot ); + if( my_clipped_time_to_rot != other_clipped_time_to_rot ) { + return false; + } + } + break; + case merge_comestible_t::merge_liquid: { + if( !made_of( LIQUID ) || !rhs.made_of( LIQUID ) ) { + return false; + } + } + [[fallthrough]]; + default: + return std::abs( get_relative_rot() - rhs.get_relative_rot() ) <= similarity_threshold; } + if( rotten() != rhs.rotten() ) { // just to be safe that rotten and unrotten food is *never* stacked. return false; @@ -1046,6 +1062,20 @@ bool item::stacks_with( const item &rhs, bool check_components, bool skip_type_c return contents.stacks_with( rhs.contents ); } +namespace +{ + +time_duration weighted_averaged_rot( const item *a, const item *b ) +{ + const int base_charges = a->charges + b->charges; + + return base_charges > 0 + ? ( a->get_rot() * a->charges + b->get_rot() * b->charges ) / base_charges + : 0_seconds; +} + +} // namespace + bool item::merge_charges( detached_ptr &&rhs, bool force ) { if( this == &*rhs ) { @@ -1059,6 +1089,8 @@ bool item::merge_charges( detached_ptr &&rhs, bool force ) safe_reference::merge( this, &*rhs ); detached_ptr del = std::move( rhs ); + const auto new_rot = weighted_averaged_rot( this, &obj ); + // Prevent overflow when either item has "near infinite" charges. if( charges >= INFINITE_CHARGES / 2 || obj.charges >= INFINITE_CHARGES / 2 ) { charges = INFINITE_CHARGES; @@ -1070,6 +1102,10 @@ bool item::merge_charges( detached_ptr &&rhs, bool force ) ( obj.item_counter ) * obj.charges ) / ( charges + obj.charges ); } charges += obj.charges; + + rot = new_rot; + set_age( std::max( age(), obj.age() ) ); + return true; } @@ -1720,7 +1756,7 @@ void item::basic_info( std::vector &info, const iteminfo_query *parts, info.emplace_back( "BASE", _( "rot (turns): " ), "", iteminfo::lower_is_better, to_turns( food->rot ) ); - info.emplace_back( "BASE", space + _( "max rot (turns): " ), + info.emplace_back( "BASE", space + _( "shelf life (turns): " ), "", iteminfo::lower_is_better, to_turns( food->get_shelf_life() ) ); info.emplace_back( "BASE", _( "last rot: " ), @@ -3494,7 +3530,7 @@ void item::combat_info( std::vector &info, const iteminfo_query *parts } void item::contents_info( std::vector &info, const iteminfo_query *parts, int batch, - bool /*debug*/ ) const + bool debug ) const { if( contents.empty() || !parts->test( iteminfo_parts::DESCRIPTION_CONTENTS ) ) { return; @@ -3572,6 +3608,22 @@ void item::contents_info( std::vector &info, const iteminfo_query *par } info.emplace_back( "DESCRIPTION", description.translated() ); } + + if( debug && contents_item && contents_item->goes_bad() ) { + info.emplace_back( "CONTAINER", space ); + info.emplace_back( "CONTAINER", _( "age (turns): " ), + "", iteminfo::lower_is_better, + to_turns( contents_item->age() ) ); + info.emplace_back( "CONTAINER", _( "rot (turns): " ), + "", iteminfo::lower_is_better, + to_turns( contents_item->rot ) ); + info.emplace_back( "CONTAINER", space + _( "shelf life (turns): " ), + "", iteminfo::lower_is_better, + to_turns( contents_item->get_shelf_life() ) ); + info.emplace_back( "CONTAINER", _( "last rot: " ), + "", iteminfo::lower_is_better, + to_turn( contents_item->last_rot_check ) ); + } } } } @@ -8646,9 +8698,8 @@ detached_ptr item::fill_with( detached_ptr &&liquid, int amount ) ammo_set( liquid->typeId(), ammo_remaining() + amount ); } else if( is_food_container() ) { item &cts = contents.front(); - // Use maximum rot between the two - cts.set_relative_rot( std::max( cts.get_relative_rot(), - liquid->get_relative_rot() ) ); + + cts.set_rot( weighted_averaged_rot( &cts, &*liquid ) ); cts.mod_charges( amount ); } else if( !is_container_empty() ) { // if container already has liquid we need to set the amount diff --git a/src/options.cpp b/src/options.cpp index 204c50cdeeaf..bee01050ceed 100644 --- a/src/options.cpp +++ b/src/options.cpp @@ -7,6 +7,7 @@ #include #include "calendar.h" +#include "cached_item_options.h" #include "cata_utility.h" #include "catacharset.h" #include "color.h" @@ -1234,6 +1235,30 @@ void options_manager::add_options_general() add_empty_line(); + add_option_group( general, Group( "comestible_merging", + to_translation( "Merge similar comestibles" ), + to_translation( "Configure how similar items are stacked." ) ), + [&]( auto & page_id ) { + add( "MERGE_COMESTIBLES", page_id, translate_marker( "Merging Mode" ), + translate_marker( "Merge similar comestibles. Legacy: default behavior. Liquid: Merge only liquid comestibles. All: Merge all comestibles." ), { + { "legacy", to_translation( "Legacy" ) }, + { "liquid", to_translation( "Liquid" ) }, + { "all", to_translation( "All" ) } + }, "all" ); + + add( "MERGE_COMESTIBLES_THRESHOLD", general, translate_marker( "Freshness similarity threshold" ), + translate_marker( "Limit maximum allowed staleness difference when merging comestibles." + " The lower the value, the more similar the items must be to merge." + " 0.0: Only merge identical items." + " 1.0: Merge comestibles regardless of its freshness." + ), + 0.0, 1.0, 0.25, 0.05 ); + + get_option( "MERGE_COMESTIBLES_THRESHOLD" ).setPrerequisites( "MERGE_COMESTIBLES", {"liquid", "all"} ); + } ); + + add_empty_line(); + add( "AUTO_PICKUP", general, translate_marker( "Auto pickup enabled" ), translate_marker( "Enable item auto pickup. Change pickup rules with the Auto Pickup Manager." ), false @@ -3369,6 +3394,16 @@ void options_manager::cache_to_globals() fov_3d_z_range = ::get_option( "FOV_3D_Z_RANGE" ); static_z_effect = ::get_option( "STATICZEFFECT" ); PICKUP_RANGE = ::get_option( "PICKUP_RANGE" ); + + merge_comestible_mode = ( [] { + const auto opt = ::get_option( "MERGE_COMESTIBLES" ); + return opt == "legacy" ? merge_comestible_t::merge_legacy + : opt == "liquid" ? merge_comestible_t::merge_liquid + : merge_comestible_t::merge_all; + } )(); + + similarity_threshold = ::get_option( "MERGE_COMESTIBLES_THRESHOLD" ); + #if defined(SDL_SOUND) sounds::sound_enabled = ::get_option( "SOUND_ENABLED" ); #endif diff --git a/tests/item_test.cpp b/tests/item_test.cpp index 18b8f83a39c0..e922cba6751b 100644 --- a/tests/item_test.cpp +++ b/tests/item_test.cpp @@ -12,6 +12,7 @@ #include "math_defines.h" #include "units.h" #include "value_ptr.h" +#include "cached_item_options.h" TEST_CASE( "item_volume", "[item]" ) { @@ -69,7 +70,9 @@ TEST_CASE( "stacking_over_time", "[item]" ) item &A = *item::spawn_temporary( "bologna" ); item &B = *item::spawn_temporary( "bologna" ); - GIVEN( "Two items with the same birthday" ) { + GIVEN( "Two items with the same birthday (stack mode: legacy)" ) { + merge_comestible_mode = merge_comestible_t::merge_legacy; + REQUIRE( A.stacks_with( B ) ); WHEN( "the items are aged different numbers of seconds" ) { A.mod_rot( A.type->comestible->spoils - 1_turns ); @@ -159,8 +162,103 @@ TEST_CASE( "stacking_over_time", "[item]" ) } } } + + GIVEN( "Two items with the same birthday (stack mode: all)" ) { + merge_comestible_mode = merge_comestible_t::merge_all; + similarity_threshold = 1.0f; + + REQUIRE( A.stacks_with( B ) ); + WHEN( "the items are aged different numbers of seconds" ) { + A.mod_rot( A.type->comestible->spoils - 1_turns ); + B.mod_rot( B.type->comestible->spoils - 3_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged the same to the minute but different numbers of seconds" ) { + A.mod_rot( A.type->comestible->spoils - 5_minutes ); + B.mod_rot( B.type->comestible->spoils - 5_minutes ); + B.mod_rot( -5_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged a few seconds different but different minutes" ) { + A.mod_rot( A.type->comestible->spoils - 5_minutes ); + B.mod_rot( B.type->comestible->spoils - 5_minutes ); + B.mod_rot( 5_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged the same to the hour but different numbers of minutes" ) { + A.mod_rot( A.type->comestible->spoils - 5_hours ); + B.mod_rot( B.type->comestible->spoils - 5_hours ); + B.mod_rot( -5_minutes ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged a few seconds different but different hours" ) { + A.mod_rot( A.type->comestible->spoils - 5_hours ); + B.mod_rot( B.type->comestible->spoils - 5_hours ); + B.mod_rot( 5_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged the same to the day but different numbers of seconds" ) { + A.mod_rot( A.type->comestible->spoils - 3_days ); + B.mod_rot( B.type->comestible->spoils - 3_days ); + B.mod_rot( -5_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged a few seconds different but different days" ) { + A.mod_rot( A.type->comestible->spoils - 3_days ); + B.mod_rot( B.type->comestible->spoils - 3_days ); + B.mod_rot( 5_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged the same to the week but different numbers of seconds" ) { + A.mod_rot( A.type->comestible->spoils - 7_days ); + B.mod_rot( B.type->comestible->spoils - 7_days ); + B.mod_rot( -5_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged a few seconds different but different weeks" ) { + A.mod_rot( A.type->comestible->spoils - 7_days ); + B.mod_rot( B.type->comestible->spoils - 7_days ); + B.mod_rot( 5_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged the same to the season but different numbers of seconds" ) { + A.mod_rot( A.type->comestible->spoils - calendar::season_length() ); + B.mod_rot( B.type->comestible->spoils - calendar::season_length() ); + B.mod_rot( -5_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + WHEN( "the items are aged a few seconds different but different seasons" ) { + A.mod_rot( A.type->comestible->spoils - calendar::season_length() ); + B.mod_rot( B.type->comestible->spoils - calendar::season_length() ); + B.mod_rot( 5_turns ); + THEN( "they stack" ) { + CHECK( A.stacks_with( B ) ); + } + } + } } + TEST_CASE( "magazine_copyfrom_extends", "[item]" ) { item gun( "glock_19" );