Skip to content

Commit c4a0abe

Browse files
authored
Merge pull request #82821 from ehughsbaird/batch-form
Add linear scaling for batch recipes
2 parents 0480888 + 47283ff commit c4a0abe

File tree

5 files changed

+163
-36
lines changed

5 files changed

+163
-36
lines changed

data/json/recipes/food/raw_grain.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,18 @@
164164
"byproducts": [ [ "chaff", 1 ] ],
165165
"components": [ [ [ "threshed_wheat", 1 ] ] ]
166166
},
167+
{
168+
"type": "recipe",
169+
"copy-from": "threshed_oats_flail",
170+
"result": "threshed_oats",
171+
"id_suffix": "flail_bulk",
172+
"name": "bulk %s",
173+
"result_mult": 10,
174+
"time": "19 m 2s",
175+
"batch_time_factors": { "mode": "linear", "setup": "9 m 2 s", "max": 5 },
176+
"byproducts": [ [ "straw_pile", 20 ] ],
177+
"components": [ [ [ "dry_oat_stalks", 10 ] ] ]
178+
},
167179
{
168180
"type": "recipe",
169181
"activity_level": "BRISK_EXERCISE",

doc/JSON/ITEM_CRAFT_AND_DISASSEMBLY.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Crafting recipes are defined as a JSON object with the following fields:
7676
"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.
7777
"container": "jar_glass_sealed", //The resulting item will be contained by the item set here, overrides default container.
7878
"container_variant": "jar_glass_sealed_strawberry_picture", //The container specified above will spawn as the specified variant, overrides the normal weighted behavior.
79-
"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.
79+
"batch_time_factors": ..., // See below for details
8080
"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.
8181
"result_mult": 2, // Multiplier for resulting items. Also multiplies container items.
8282
"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:
117117
]
118118
```
119119

120+
#### `batch_time_factors`
121+
122+
`batch_time_factors supports several formats, with two different scaling functions.
123+
124+
Logistic scaling provides savings of some percent once the batch reaches a certain size.
125+
```jsonc
126+
"batch_time_factors": [ 25, 15 ], // legacy
127+
"batch_time_factors": { "mode": "logistic", "percent": 25, "at": 15 }
128+
```
129+
130+
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.
131+
132+
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))`.
133+
In other words, max does not limit the max batch size, it merely specifies when the setup cost will be applied again.
134+
It is specified as follows:
135+
```jsonc
136+
"batch_time_factors": { "mode": "linear": "setup": "12 m" },
137+
"batch_time_factors": { "mode": "linear": "setup": "12 m", "max": 20 },
138+
```
139+
120140
## Practice recipes
121141

122142
Recipes may instead be defined with type "practice", to make them appear in the "PRACTICE" tab of

src/generic_factory.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2205,6 +2205,19 @@ class time_bound_reader : public bound_reader<T>
22052205
};
22062206
};
22072207

2208+
struct percentile_reader : public generic_typed_reader<percentile_reader> {
2209+
double lower;
2210+
double upper;
2211+
2212+
explicit percentile_reader( double l = std::numeric_limits<double>::max(),
2213+
double h = std::numeric_limits<double>::max() ) : lower( l ), upper( h ) {}
2214+
2215+
double get_next( const JsonValue &jv ) const {
2216+
double ret = jv.get_float() / 100.0;
2217+
return bound_check( lower, upper, jv, ret );
2218+
}
2219+
};
2220+
22082221
struct weakpoints;
22092222

22102223
struct weakpoints_reader : generic_typed_reader<weakpoints_reader> {

src/recipe.cpp

Lines changed: 90 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,30 @@ int64_t recipe::time_to_craft_moves( const Character &guy, recipe_time_flag flag
102102
return time * proficiency_time_maluses( guy );
103103
}
104104

105+
double batch_savings::apply( double time, int batch_size ) const
106+
{
107+
if( const linear *lin = std::get_if<linear>( &data ) ) {
108+
int reps = std::ceil( batch_size / static_cast<double>( lin->max_batch.value_or( batch_size ) ) );
109+
return ( reps * lin->offset ) + ( batch_size * ( time - lin->offset ) );
110+
}
111+
if( const logistic *log = std::get_if<logistic>( &data ) ) {
112+
if( log->rscale == 0.0f ) {
113+
return time * batch_size;
114+
}
115+
double ret = 0.0;
116+
// recipe benefits from batching, so batching scale factor needs to be calculated
117+
// At batch_rsize, incremental time increase is 99.5% of batch_rscale
118+
const double scale = log->rsize / 6.0f;
119+
for( int x = 0; x < batch_size; x++ ) {
120+
// scaled logistic function output
121+
const double logf = ( 2.0 / ( 1.0 + std::exp( -( x / scale ) ) ) ) - 1.0;
122+
ret += time * ( 1.0 - ( log->rscale * logf ) );
123+
}
124+
return ret;
125+
}
126+
return time * batch_size;
127+
}
128+
105129
int64_t recipe::batch_time( const Character &guy, int batch, float multiplier,
106130
size_t assistants ) const
107131
{
@@ -113,26 +137,7 @@ int64_t recipe::batch_time( const Character &guy, int batch, float multiplier,
113137
}
114138

115139
const double local_time = static_cast<double>( time_to_craft_moves( guy ) ) / multiplier;
116-
117-
// if recipe does not benefit from batching and we have no assistants, don't do unnecessary additional calculations
118-
if( batch_rscale == 0.0 && assistants == 0 ) {
119-
return static_cast<int64_t>( local_time ) * batch;
120-
}
121-
122-
double total_time = 0.0;
123-
// if recipe does not benefit from batching but we do have assistants, skip calculating the batching scale factor
124-
if( batch_rscale == 0.0f ) {
125-
total_time = local_time * batch;
126-
} else {
127-
// recipe benefits from batching, so batching scale factor needs to be calculated
128-
// At batch_rsize, incremental time increase is 99.5% of batch_rscale
129-
const double scale = batch_rsize / 6.0f;
130-
for( int x = 0; x < batch; x++ ) {
131-
// scaled logistic function output
132-
const double logf = ( 2.0 / ( 1.0 + std::exp( -( x / scale ) ) ) ) - 1.0;
133-
total_time += local_time * ( 1.0 - ( batch_rscale * logf ) );
134-
}
135-
}
140+
double total_time = batch_info.apply( local_time, batch );
136141

137142
//Assistants can decrease the time for production but never less than that of one unit
138143
if( assistants == 1 ) {
@@ -152,6 +157,14 @@ bool recipe::has_flag( const std::string &flag_name ) const
152157
return flags.count( flag_name );
153158
}
154159

160+
struct time_duration_as_moves_reader : public generic_typed_reader<time_duration_as_moves_reader> {
161+
int64_t get_next( const JsonValue &jv ) const {
162+
time_duration ret;
163+
jv.read( ret );
164+
return to_moves<int64_t>( ret );
165+
}
166+
};
167+
155168
void recipe::load( const JsonObject &jo, const std::string_view src )
156169
{
157170
abstract = jo.has_string( "abstract" );
@@ -221,10 +234,7 @@ void recipe::load( const JsonObject &jo, const std::string_view src )
221234
return;
222235
}
223236

224-
if( jo.has_string( "time" ) ) {
225-
time = to_moves<int>( read_from_json_string<time_duration>( jo.get_member( "time" ),
226-
time_duration::units ) );
227-
}
237+
optional( jo, was_loaded, "time", time, time_duration_as_moves_reader{}, 0 );
228238
optional( jo, was_loaded, "difficulty", difficulty, numeric_bound_reader<int> {0, MAX_SKILL} );
229239
optional( jo, was_loaded, "flags", flags );
230240

@@ -237,10 +247,11 @@ void recipe::load( const JsonObject &jo, const std::string_view src )
237247
optional( jo, false, "container_variant", container_variant );
238248
optional( jo, was_loaded, "sealed", sealed, true );
239249

240-
if( jo.has_array( "batch_time_factors" ) ) {
241-
JsonArray batch = jo.get_array( "batch_time_factors" );
242-
batch_rscale = batch.get_int( 0 ) / 100.0;
243-
batch_rsize = batch.get_int( 1 );
250+
optional( jo, was_loaded, "batch_time_factors", batch_info );
251+
if( batch_savings::linear *lin = std::get_if<batch_savings::linear>( &batch_info.data ) ) {
252+
if( lin->offset > time ) {
253+
jo.throw_error( "batch scaling time greater than recipe time" );
254+
}
244255
}
245256

246257
optional( jo, was_loaded, "charges", charges );
@@ -1195,11 +1206,25 @@ std::string recipe::required_all_skills_string( const std::map<skill_id, int> &s
11951206
return required_skills_as_string( skillList );
11961207
}
11971208

1209+
std::string batch_savings::savings_string() const
1210+
{
1211+
if( const linear *lin = std::get_if<linear>( &data ) ) {
1212+
std::string time_saved = to_string( time_duration::from_moves<int>( lin->offset ) );
1213+
if( lin->max_batch.has_value() ) {
1214+
return string_format( _( "%s per unit to %d units" ), time_saved, lin->max_batch.value() );
1215+
} else {
1216+
return string_format( _( "%s per unit" ), time_saved );
1217+
}
1218+
}
1219+
if( const logistic *log = std::get_if<logistic>( &data ) ) {
1220+
return string_format( _( "%d%% at >%d units" ), static_cast<int>( log->rscale * 100 ), log->rsize );
1221+
}
1222+
return _( "none" );
1223+
}
1224+
11981225
std::string recipe::batch_savings_string() const
11991226
{
1200-
return ( batch_rsize != 0 ) ?
1201-
string_format( _( "%d%% at >%d units" ), static_cast<int>( batch_rscale * 100 ), batch_rsize )
1202-
: _( "none" );
1227+
return batch_info.savings_string();
12031228
}
12041229

12051230
std::string recipe::result_name( const bool decorated ) const
@@ -1521,6 +1546,40 @@ void recipe::incorporate_build_reqs()
15211546
}
15221547
}
15231548

1549+
void batch_savings::deserialize( const JsonValue &jv )
1550+
{
1551+
if( jv.test_array() ) {
1552+
JsonArray ja = jv.get_array();
1553+
logistic ret;
1554+
ja.read( 0, ret.rscale );
1555+
ret.rscale /= 100.0;
1556+
ja.read( 1, ret.rsize );
1557+
data = ret;
1558+
if( ret.rscale > 1.0 || ret.rscale <= 0.0 || ret.rsize < 1 ) {
1559+
jv.throw_error( "Invalid batch factors" );
1560+
}
1561+
return;
1562+
}
1563+
JsonObject jo = jv.get_object();
1564+
std::string mode = jo.get_string( "mode" );
1565+
if( mode == "linear" ) {
1566+
linear ret;
1567+
mandatory( jo, false, "setup", ret.offset, time_duration_as_moves_reader{} );
1568+
optional( jo, false, "max", ret.max_batch );
1569+
if( ret.max_batch.value_or( 1 ) < 1 ) {
1570+
jo.throw_error( "Invalid max value" );
1571+
}
1572+
data = ret;
1573+
} else if( mode == "logistic" ) {
1574+
logistic ret;
1575+
mandatory( jo, false, "percent", ret.rscale, percentile_reader{0, 100} );
1576+
mandatory( jo, false, "at", ret.rsize, numeric_bound_reader{1} );
1577+
data = ret;
1578+
} else {
1579+
jo.throw_error( string_format( "Unrecognized mode %s", mode ) );
1580+
}
1581+
}
1582+
15241583
void recipe_proficiency::deserialize( const JsonObject &jo )
15251584
{
15261585
load( jo );

src/recipe.h

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <string>
1212
#include <string_view>
1313
#include <utility>
14+
#include <variant>
1415
#include <vector>
1516

1617
#include "build_reqs.h"
@@ -22,6 +23,7 @@
2223

2324
class Character;
2425
class JsonObject;
26+
class JsonValue;
2527
class cata_variant;
2628
class item;
2729
class item_components;
@@ -84,6 +86,30 @@ struct practice_recipe_data {
8486
void deserialize( const JsonObject &jo );
8587
};
8688

89+
struct batch_savings {
90+
// Linear, time taken for recipe of time T and batch of N is:
91+
// ((N/max_batch) * offset) + (N * (T - offset))
92+
struct linear {
93+
int64_t offset;
94+
std::optional<int> max_batch;
95+
};
96+
struct logistic {
97+
// maximum achievable time reduction, as percentage of the original time.
98+
// if zero then the recipe has no batch crafting time reduction.
99+
double rscale;
100+
int rsize; // minimum batch size to needed to reach batch_rscale
101+
};
102+
struct none { };
103+
104+
std::variant<linear, logistic, none> data;
105+
void deserialize( const JsonValue &jv );
106+
107+
double apply( double time, int batch_size ) const;
108+
std::string savings_string() const;
109+
110+
batch_savings() : data( none{} ) {}
111+
};
112+
87113
class recipe
88114
{
89115
friend class recipe_dictionary;
@@ -358,10 +384,7 @@ class recipe
358384
/** Item group representing byproducts **/
359385
std::optional<item_group_id> byproduct_group;
360386

361-
// maximum achievable time reduction, as percentage of the original time.
362-
// if zero then the recipe has no batch crafting time reduction.
363-
double batch_rscale = 0.0;
364-
int batch_rsize = 0; // minimum batch size to needed to reach batch_rscale
387+
batch_savings batch_info;
365388
int result_mult = 1; // used by certain batch recipes that create more than one stack of the result
366389
update_mapgen_id blueprint;
367390
translation bp_name;

0 commit comments

Comments
 (0)