diff --git a/data/json/recipes/food/raw_grain.json b/data/json/recipes/food/raw_grain.json index a9177ab1c2861..be29251d92b2d 100644 --- a/data/json/recipes/food/raw_grain.json +++ b/data/json/recipes/food/raw_grain.json @@ -164,6 +164,18 @@ "byproducts": [ [ "chaff", 1 ] ], "components": [ [ [ "threshed_wheat", 1 ] ] ] }, + { + "type": "recipe", + "copy-from": "threshed_oats_flail", + "result": "threshed_oats", + "id_suffix": "flail_bulk", + "name": "bulk %s", + "result_mult": 10, + "time": "19 m 2s", + "batch_time_factors": { "mode": "linear", "setup": "9 m 2 s", "max": 5 }, + "byproducts": [ [ "straw_pile", 20 ] ], + "components": [ [ [ "dry_oat_stalks", 10 ] ] ] + }, { "type": "recipe", "activity_level": "BRISK_EXERCISE", diff --git a/doc/JSON/ITEM_CRAFT_AND_DISASSEMBLY.md b/doc/JSON/ITEM_CRAFT_AND_DISASSEMBLY.md index ab3a26bc7cb25..bcea9b097370d 100644 --- a/doc/JSON/ITEM_CRAFT_AND_DISASSEMBLY.md +++ b/doc/JSON/ITEM_CRAFT_AND_DISASSEMBLY.md @@ -76,7 +76,7 @@ Crafting recipes are defined as a JSON object with the following fields: "contained": true, // Boolean value which defines if the resulting item comes in its designated container. Automatically set to true if any container is defined in the recipe. "container": "jar_glass_sealed", //The resulting item will be contained by the item set here, overrides default container. "container_variant": "jar_glass_sealed_strawberry_picture", //The container specified above will spawn as the specified variant, overrides the normal weighted behavior. -"batch_time_factors": [25, 15], // Optional factors for batch crafting time reduction. First number specifies maximum crafting time reduction as percentage, and the second number the minimal batch size to reach that number. In this example given batch size of 20 the last 6 crafts will take only 3750 time units. +"batch_time_factors": ..., // See below for details "charges": 2, // Number of resulting items/charges per craft. Uses default charges if not set. If a container is set, this is the amount that gets put inside it, capped by container capacity. "result_mult": 2, // Multiplier for resulting items. Also multiplies container items. "flags": [ // A set of strings describing boolean features of the recipe @@ -117,6 +117,26 @@ Crafting recipes are defined as a JSON object with the following fields: ] ``` +#### `batch_time_factors` + +`batch_time_factors supports several formats, with two different scaling functions. + +Logistic scaling provides savings of some percent once the batch reaches a certain size. +```jsonc +"batch_time_factors": [ 25, 15 ], // legacy +"batch_time_factors": { "mode": "logistic", "percent": 25, "at": 15 } +``` + +This shows both formats for logistic scaling. The first number specifies the maximum crafting time reduction as a percentage, and the second number the minimal batch size to reach that number. If this recipe took 5000 moves, when made in a batch of 20, the last 5 units would take only 3750 moves to produce. + +Linear scaling provides purely linear scaling. There are two parameters, the `setup` time `T`, and the max batch size `M`, which is optional. The time taken for a batch of size `n` for a recipe which takes `t` time is: `(ceil(n/M) * T) + (n * (t - T))`. If `M` is not specified, it defaults to n, simplifying to `T + (n * (t - T))`. +In other words, max does not limit the max batch size, it merely specifies when the setup cost will be applied again. +It is specified as follows: +```jsonc +"batch_time_factors": { "mode": "linear": "setup": "12 m" }, +"batch_time_factors": { "mode": "linear": "setup": "12 m", "max": 20 }, +``` + ## Practice recipes Recipes may instead be defined with type "practice", to make them appear in the "PRACTICE" tab of diff --git a/src/generic_factory.h b/src/generic_factory.h index fc2c1e5db2bcb..a2b6d4113fa30 100644 --- a/src/generic_factory.h +++ b/src/generic_factory.h @@ -2168,6 +2168,19 @@ class time_bound_reader : public bound_reader }; }; +struct percentile_reader : public generic_typed_reader { + double lower; + double upper; + + explicit percentile_reader( double l = std::numeric_limits::max(), + double h = std::numeric_limits::max() ) : lower( l ), upper( h ) {} + + double get_next( const JsonValue &jv ) const { + double ret = jv.get_float() / 100.0; + return bound_check( lower, upper, jv, ret ); + } +}; + struct weakpoints; struct weakpoints_reader : generic_typed_reader { diff --git a/src/recipe.cpp b/src/recipe.cpp index 8b0f3b7938880..79dcdf16a5d6c 100644 --- a/src/recipe.cpp +++ b/src/recipe.cpp @@ -102,6 +102,30 @@ int64_t recipe::time_to_craft_moves( const Character &guy, recipe_time_flag flag return time * proficiency_time_maluses( guy ); } +double batch_savings::apply( double time, int batch_size ) const +{ + if( const linear *lin = std::get_if( &data ) ) { + int reps = std::ceil( batch_size / static_cast( lin->max_batch.value_or( batch_size ) ) ); + return ( reps * lin->offset ) + ( batch_size * ( time - lin->offset ) ); + } + if( const logistic *log = std::get_if( &data ) ) { + if( log->rscale == 0.0f ) { + return time * batch_size; + } + double ret = 0.0; + // recipe benefits from batching, so batching scale factor needs to be calculated + // At batch_rsize, incremental time increase is 99.5% of batch_rscale + const double scale = log->rsize / 6.0f; + for( int x = 0; x < batch_size; x++ ) { + // scaled logistic function output + const double logf = ( 2.0 / ( 1.0 + std::exp( -( x / scale ) ) ) ) - 1.0; + ret += time * ( 1.0 - ( log->rscale * logf ) ); + } + return ret; + } + return time * batch_size; +} + int64_t recipe::batch_time( const Character &guy, int batch, float multiplier, size_t assistants ) const { @@ -113,26 +137,7 @@ int64_t recipe::batch_time( const Character &guy, int batch, float multiplier, } const double local_time = static_cast( time_to_craft_moves( guy ) ) / multiplier; - - // if recipe does not benefit from batching and we have no assistants, don't do unnecessary additional calculations - if( batch_rscale == 0.0 && assistants == 0 ) { - return static_cast( local_time ) * batch; - } - - double total_time = 0.0; - // if recipe does not benefit from batching but we do have assistants, skip calculating the batching scale factor - if( batch_rscale == 0.0f ) { - total_time = local_time * batch; - } else { - // recipe benefits from batching, so batching scale factor needs to be calculated - // At batch_rsize, incremental time increase is 99.5% of batch_rscale - const double scale = batch_rsize / 6.0f; - for( int x = 0; x < batch; x++ ) { - // scaled logistic function output - const double logf = ( 2.0 / ( 1.0 + std::exp( -( x / scale ) ) ) ) - 1.0; - total_time += local_time * ( 1.0 - ( batch_rscale * logf ) ); - } - } + double total_time = batch_info.apply( local_time, batch ); //Assistants can decrease the time for production but never less than that of one unit if( assistants == 1 ) { @@ -152,6 +157,14 @@ bool recipe::has_flag( const std::string &flag_name ) const return flags.count( flag_name ); } +struct time_duration_as_moves_reader : public generic_typed_reader { + int64_t get_next( const JsonValue &jv ) const { + time_duration ret; + jv.read( ret ); + return to_moves( ret ); + } +}; + void recipe::load( const JsonObject &jo, const std::string_view src ) { abstract = jo.has_string( "abstract" ); @@ -221,10 +234,7 @@ void recipe::load( const JsonObject &jo, const std::string_view src ) return; } - if( jo.has_string( "time" ) ) { - time = to_moves( read_from_json_string( jo.get_member( "time" ), - time_duration::units ) ); - } + optional( jo, was_loaded, "time", time, time_duration_as_moves_reader{}, 0 ); optional( jo, was_loaded, "difficulty", difficulty, numeric_bound_reader {0, MAX_SKILL} ); optional( jo, was_loaded, "flags", flags ); @@ -237,10 +247,11 @@ void recipe::load( const JsonObject &jo, const std::string_view src ) optional( jo, false, "container_variant", container_variant ); optional( jo, was_loaded, "sealed", sealed, true ); - if( jo.has_array( "batch_time_factors" ) ) { - JsonArray batch = jo.get_array( "batch_time_factors" ); - batch_rscale = batch.get_int( 0 ) / 100.0; - batch_rsize = batch.get_int( 1 ); + optional( jo, was_loaded, "batch_time_factors", batch_info ); + if( batch_savings::linear *lin = std::get_if( &batch_info.data ) ) { + if( lin->offset > time ) { + jo.throw_error( "batch scaling time greater than recipe time" ); + } } optional( jo, was_loaded, "charges", charges ); @@ -1195,11 +1206,25 @@ std::string recipe::required_all_skills_string( const std::map &s return required_skills_as_string( skillList ); } +std::string batch_savings::savings_string() const +{ + if( const linear *lin = std::get_if( &data ) ) { + std::string time_saved = to_string( time_duration::from_moves( lin->offset ) ); + if( lin->max_batch.has_value() ) { + return string_format( _( "%s per unit to %d units" ), time_saved, lin->max_batch.value() ); + } else { + return string_format( _( "%s per unit" ), time_saved ); + } + } + if( const logistic *log = std::get_if( &data ) ) { + return string_format( _( "%d%% at >%d units" ), static_cast( log->rscale * 100 ), log->rsize ); + } + return _( "none" ); +} + std::string recipe::batch_savings_string() const { - return ( batch_rsize != 0 ) ? - string_format( _( "%d%% at >%d units" ), static_cast( batch_rscale * 100 ), batch_rsize ) - : _( "none" ); + return batch_info.savings_string(); } std::string recipe::result_name( const bool decorated ) const @@ -1521,6 +1546,40 @@ void recipe::incorporate_build_reqs() } } +void batch_savings::deserialize( const JsonValue &jv ) +{ + if( jv.test_array() ) { + JsonArray ja = jv.get_array(); + logistic ret; + ja.read( 0, ret.rscale ); + ret.rscale /= 100.0; + ja.read( 1, ret.rsize ); + data = ret; + if( ret.rscale > 1.0 || ret.rscale <= 0.0 || ret.rsize < 1 ) { + jv.throw_error( "Invalid batch factors" ); + } + return; + } + JsonObject jo = jv.get_object(); + std::string mode = jo.get_string( "mode" ); + if( mode == "linear" ) { + linear ret; + mandatory( jo, false, "setup", ret.offset, time_duration_as_moves_reader{} ); + optional( jo, false, "max", ret.max_batch ); + if( ret.max_batch.value_or( 1 ) < 1 ) { + jo.throw_error( "Invalid max value" ); + } + data = ret; + } else if( mode == "logistic" ) { + logistic ret; + mandatory( jo, false, "percent", ret.rscale, percentile_reader{0, 100} ); + mandatory( jo, false, "at", ret.rsize, numeric_bound_reader{1} ); + data = ret; + } else { + jo.throw_error( string_format( "Unrecognized mode %s", mode ) ); + } +} + void recipe_proficiency::deserialize( const JsonObject &jo ) { load( jo ); diff --git a/src/recipe.h b/src/recipe.h index ad540ad947fa8..ed9278d7e7053 100644 --- a/src/recipe.h +++ b/src/recipe.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include "build_reqs.h" @@ -22,6 +23,7 @@ class Character; class JsonObject; +class JsonValue; class cata_variant; class item; class item_components; @@ -84,6 +86,30 @@ struct practice_recipe_data { void deserialize( const JsonObject &jo ); }; +struct batch_savings { + // Linear, time taken for recipe of time T and batch of N is: + // ((N/max_batch) * offset) + (N * (T - offset)) + struct linear { + int64_t offset; + std::optional max_batch; + }; + struct logistic { + // maximum achievable time reduction, as percentage of the original time. + // if zero then the recipe has no batch crafting time reduction. + double rscale; + int rsize; // minimum batch size to needed to reach batch_rscale + }; + struct none { }; + + std::variant data; + void deserialize( const JsonValue &jv ); + + double apply( double time, int batch_size ) const; + std::string savings_string() const; + + batch_savings() : data( none{} ) {} +}; + class recipe { friend class recipe_dictionary; @@ -358,10 +384,7 @@ class recipe /** Item group representing byproducts **/ std::optional byproduct_group; - // maximum achievable time reduction, as percentage of the original time. - // if zero then the recipe has no batch crafting time reduction. - double batch_rscale = 0.0; - int batch_rsize = 0; // minimum batch size to needed to reach batch_rscale + batch_savings batch_info; int result_mult = 1; // used by certain batch recipes that create more than one stack of the result update_mapgen_id blueprint; translation bp_name;