1 #include "catch/catch.hpp"
2
3 #include "calendar.h"
4 #include "character.h"
5 #include "item.h"
6 #include "options.h"
7 #include "player_helpers.h"
8 #include "type_id.h"
9 #include "units.h"
10
11 static const efftype_id effect_winded( "winded" );
12
13 static const move_mode_id move_mode_walk( "walk" );
14 static const move_mode_id move_mode_run( "run" );
15 static const move_mode_id move_mode_crouch( "crouch" );
16 // These test cases cover stamina-related functions in the `Character` class, including:
17 //
18 // - stamina_move_cost_modifier
19 // - burn_move_stamina
20 // - mod_stamina
21 // - update_stamina
22 //
23 // To run all tests in this file:
24 //
25 // tests/cata_test [stamina]
26 //
27 // Other tags used include: [cost], [move], [burn], [update], [regen]. [encumbrance]
28
29 // TODO: cover additional aspects of `burn_move_stamina` and `update_stamina`:
30 // - stamina burn is modified by bionic muscles
31 // - stamina recovery is modified by "bio_gills"
32 // - stimulants (positive or negative) affect stamina recovery in mysterious ways
33
34 // Helpers
35 // -------
36
37 // See also `clear_character` in `tests/player_helpers.cpp`
38
39 // Remove "winded" effect from the Character (but do not change stamina)
catch_breath(Character & dummy)40 static void catch_breath( Character &dummy )
41 {
42 dummy.remove_effect( effect_winded );
43 REQUIRE_FALSE( dummy.has_effect( effect_winded ) );
44 }
45
46 // Return `stamina_move_cost_modifier` in the given move_mode with [0.0 .. 1.0] stamina remaining
move_cost_mod(Character & dummy,const move_mode_id & move_mode,float stamina_proportion=1.0)47 static float move_cost_mod( Character &dummy, const move_mode_id &move_mode,
48 float stamina_proportion = 1.0 )
49 {
50 // Reset and be able to run
51 clear_avatar();
52 catch_breath( dummy );
53 REQUIRE( dummy.can_run() );
54
55 // Walk, run, or crouch
56 dummy.set_movement_mode( move_mode );
57 REQUIRE( dummy.movement_mode_is( move_mode ) );
58
59 // Adjust stamina to desired proportion and ensure it was set correctly
60 int new_stamina = static_cast<int>( stamina_proportion * dummy.get_stamina_max() );
61 dummy.set_stamina( new_stamina );
62 REQUIRE( dummy.get_stamina() == new_stamina );
63
64 // The point of it all: move cost modifier
65 return dummy.stamina_move_cost_modifier();
66 }
67
68 // Return amount of stamina burned per turn by `burn_move_stamina` in the given movement mode.
actual_burn_rate(Character & dummy,const move_mode_id & move_mode)69 static int actual_burn_rate( Character &dummy, const move_mode_id &move_mode )
70 {
71 // Ensure we can run if necessary (aaaa zombies!)
72 dummy.set_stamina( dummy.get_stamina_max() );
73 catch_breath( dummy );
74 REQUIRE( dummy.can_run() );
75
76 // Walk, run, or crouch
77 dummy.set_movement_mode( move_mode );
78 REQUIRE( dummy.movement_mode_is( move_mode ) );
79
80 // Measure stamina burned, and ensure it is nonzero
81 int before_stam = dummy.get_stamina();
82 dummy.burn_move_stamina( to_moves<int>( 1_turns ) );
83 int after_stam = dummy.get_stamina();
84 REQUIRE( before_stam > after_stam );
85
86 // How much stamina was actually burned?
87 return before_stam - after_stam;
88 }
89
90 // Burden the Character with a given proportion [0.0 .. inf) of their maximum weight capacity
burden_player(Character & dummy,float burden_proportion)91 static void burden_player( Character &dummy, float burden_proportion )
92 {
93 units::mass capacity = dummy.weight_capacity();
94 int units = static_cast<int>( capacity * burden_proportion / 1_gram );
95
96 // Add a pile of test platinum bits (1g/unit) to reach the desired weight capacity
97 if( burden_proportion > 0.0 ) {
98 item pile( "test_platinum_bit", calendar::turn, units );
99 dummy.i_add( pile );
100 }
101
102 // Ensure we are carrying the expected number of grams
103 REQUIRE( to_gram( dummy.weight_carried() ) == units );
104 }
105
106 // Return amount of stamina burned per turn by `burn_move_stamina` in the given movement mode,
107 // while carrying the given proportion [0.0, inf) of their maximum weight capacity.
burdened_burn_rate(Character & dummy,const move_mode_id & move_mode,float burden_proportion=0.0)108 static int burdened_burn_rate( Character &dummy, const move_mode_id &move_mode,
109 float burden_proportion = 0.0 )
110 {
111 clear_avatar();
112 burden_player( dummy, burden_proportion );
113 return actual_burn_rate( dummy, move_mode );
114 }
115
116 // Return the actual amount of stamina regenerated by `update_stamina` in the given number of moves
actual_regen_rate(Character & dummy,int moves)117 static float actual_regen_rate( Character &dummy, int moves )
118 {
119 // Start at 10% stamina, plenty of space for regen
120 dummy.set_stamina( dummy.get_stamina_max() / 10 );
121 REQUIRE( dummy.get_stamina() == dummy.get_stamina_max() / 10 );
122
123 int before_stam = dummy.get_stamina();
124 dummy.update_stamina( moves );
125 int after_stam = dummy.get_stamina();
126
127 return after_stam - before_stam;
128 }
129
130 // Test cases
131 // ----------
132
133 TEST_CASE( "stamina movement cost modifier", "[stamina][cost]" )
134 {
135 Character &dummy = get_player_character();
136
137 SECTION( "running cost is double walking cost for the same stamina level" ) {
138 CHECK( move_cost_mod( dummy, move_mode_run, 1.0 ) == 2 * move_cost_mod( dummy, move_mode_walk,
139 1.0 ) );
140 CHECK( move_cost_mod( dummy, move_mode_run, 0.5 ) == 2 * move_cost_mod( dummy, move_mode_walk,
141 0.5 ) );
142 CHECK( move_cost_mod( dummy, move_mode_run, 0.0 ) == 2 * move_cost_mod( dummy, move_mode_walk,
143 0.0 ) );
144 }
145
146 SECTION( "walking cost is double crouching cost for the same stamina level" ) {
147 CHECK( move_cost_mod( dummy, move_mode_walk, 1.0 ) == 2 * move_cost_mod( dummy, move_mode_crouch,
148 1.0 ) );
149 CHECK( move_cost_mod( dummy, move_mode_walk, 0.5 ) == 2 * move_cost_mod( dummy, move_mode_crouch,
150 0.5 ) );
151 CHECK( move_cost_mod( dummy, move_mode_walk, 0.0 ) == 2 * move_cost_mod( dummy, move_mode_crouch,
152 0.0 ) );
153 }
154
155 SECTION( "running cost goes from 2.0 to 1.0 as stamina goes to zero" ) {
156 CHECK( move_cost_mod( dummy, move_mode_run, 1.00 ) == Approx( 2.00 ) );
157 CHECK( move_cost_mod( dummy, move_mode_run, 0.75 ) == Approx( 1.75 ) );
158 CHECK( move_cost_mod( dummy, move_mode_run, 0.50 ) == Approx( 1.50 ) );
159 CHECK( move_cost_mod( dummy, move_mode_run, 0.25 ) == Approx( 1.25 ) );
160 CHECK( move_cost_mod( dummy, move_mode_run, 0.00 ) == Approx( 1.00 ) );
161 }
162
163 SECTION( "walking cost goes from 1.0 to 0.5 as stamina goes to zero" ) {
164 CHECK( move_cost_mod( dummy, move_mode_walk, 1.00 ) == Approx( 1.000 ) );
165 CHECK( move_cost_mod( dummy, move_mode_walk, 0.75 ) == Approx( 0.875 ) );
166 CHECK( move_cost_mod( dummy, move_mode_walk, 0.50 ) == Approx( 0.750 ) );
167 CHECK( move_cost_mod( dummy, move_mode_walk, 0.25 ) == Approx( 0.625 ) );
168 CHECK( move_cost_mod( dummy, move_mode_walk, 0.00 ) == Approx( 0.500 ) );
169 }
170
171 SECTION( "crouching cost goes from 0.5 to 0.25 as stamina goes to zero" ) {
172 CHECK( move_cost_mod( dummy, move_mode_crouch, 1.00 ) == Approx( 0.5000 ) );
173 CHECK( move_cost_mod( dummy, move_mode_crouch, 0.75 ) == Approx( 0.4375 ) );
174 CHECK( move_cost_mod( dummy, move_mode_crouch, 0.50 ) == Approx( 0.3750 ) );
175 CHECK( move_cost_mod( dummy, move_mode_crouch, 0.25 ) == Approx( 0.3125 ) );
176 CHECK( move_cost_mod( dummy, move_mode_crouch, 0.00 ) == Approx( 0.2500 ) );
177 }
178 }
179
180 TEST_CASE( "modify character stamina", "[stamina][modify]" )
181 {
182 Character &dummy = get_player_character();
183 clear_avatar();
184 catch_breath( dummy );
185 REQUIRE_FALSE( dummy.is_npc() );
186 REQUIRE_FALSE( dummy.has_effect( effect_winded ) );
187
188 GIVEN( "character has less than full stamina" ) {
189 int lost_stamina = dummy.get_stamina_max() / 2;
190 dummy.set_stamina( dummy.get_stamina_max() - lost_stamina );
191 REQUIRE( dummy.get_stamina() + lost_stamina == dummy.get_stamina_max() );
192
193 WHEN( "they regain only part of their lost stamina" ) {
194 dummy.mod_stamina( lost_stamina / 2 );
195
196 THEN( "stamina is less than maximum" ) {
197 CHECK( dummy.get_stamina() < dummy.get_stamina_max() );
198 }
199 }
200
201 WHEN( "they regain all of their lost stamina" ) {
202 dummy.mod_stamina( lost_stamina );
203
204 THEN( "stamina is at maximum" ) {
205 CHECK( dummy.get_stamina() == dummy.get_stamina_max() );
206 }
207 }
208
209 WHEN( "they regain more stamina than they lost" ) {
210 dummy.mod_stamina( lost_stamina + 1 );
211
212 THEN( "stamina is at maximum" ) {
213 CHECK( dummy.get_stamina() == dummy.get_stamina_max() );
214 }
215 }
216
217 WHEN( "they lose only part of their remaining stamina" ) {
218 dummy.mod_stamina( -( dummy.get_stamina() / 2 ) );
219
220 THEN( "stamina is above zero" ) {
221 CHECK( dummy.get_stamina() > 0 );
222
223 AND_THEN( "they do not become winded" ) {
224 REQUIRE_FALSE( dummy.has_effect( effect_winded ) );
225 }
226 }
227 }
228
229 WHEN( "they lose all of their remaining stamina" ) {
230 dummy.mod_stamina( -( dummy.get_stamina() ) );
231
232 THEN( "stamina is at zero" ) {
233 CHECK( dummy.get_stamina() == 0 );
234
235 AND_THEN( "they do not become winded" ) {
236 REQUIRE_FALSE( dummy.has_effect( effect_winded ) );
237 }
238 }
239 }
240
241 WHEN( "they lose more stamina than they have remaining" ) {
242 dummy.mod_stamina( -( dummy.get_stamina() + 1 ) );
243
244 THEN( "stamina is at zero" ) {
245 CHECK( dummy.get_stamina() == 0 );
246
247 AND_THEN( "they become winded" ) {
248 REQUIRE( dummy.has_effect( effect_winded ) );
249 }
250 }
251 }
252 }
253 }
254
255 TEST_CASE( "stamina burn for movement", "[stamina][burn][move]" )
256 {
257 Character &dummy = get_player_character();
258
259 // Defined in game_balance.json
260 const int normal_burn_rate = get_option<int>( "PLAYER_BASE_STAMINA_BURN_RATE" );
261 REQUIRE( normal_burn_rate > 0 );
262
263 GIVEN( "player is naked and unburdened" ) {
264 THEN( "walking burns the normal amount of stamina per turn" ) {
265 CHECK( burdened_burn_rate( dummy, move_mode_walk, 0.0 ) == normal_burn_rate );
266 }
267
268 THEN( "running burns 14 times the normal amount of stamina per turn" ) {
269 CHECK( burdened_burn_rate( dummy, move_mode_run, 0.0 ) == normal_burn_rate * 14 );
270 }
271
272 THEN( "crouching burns 1/2 the normal amount of stamina per turn" ) {
273 CHECK( burdened_burn_rate( dummy, move_mode_crouch, 0.0 ) == normal_burn_rate / 2 );
274 }
275 }
276
277 GIVEN( "player is at their maximum weight capacity" ) {
278 THEN( "walking burns the normal amount of stamina per turn" ) {
279 CHECK( burdened_burn_rate( dummy, move_mode_walk, 1.0 ) == normal_burn_rate );
280 }
281
282 THEN( "running burns 14 times the normal amount of stamina per turn" ) {
283 CHECK( burdened_burn_rate( dummy, move_mode_run, 1.0 ) == normal_burn_rate * 14 );
284 }
285
286 THEN( "crouching burns 1/2 the normal amount of stamina per turn" ) {
287 CHECK( burdened_burn_rate( dummy, move_mode_crouch, 1.0 ) == normal_burn_rate / 2 );
288 }
289 }
290
291 GIVEN( "player is overburdened" ) {
292 THEN( "walking burn rate increases by 1 for each percent overburdened" ) {
293 CHECK( burdened_burn_rate( dummy, move_mode_walk, 1.01 ) == normal_burn_rate + 1 );
294 CHECK( burdened_burn_rate( dummy, move_mode_walk, 1.02 ) == normal_burn_rate + 2 );
295 CHECK( burdened_burn_rate( dummy, move_mode_walk, 1.50 ) == normal_burn_rate + 50 );
296 CHECK( burdened_burn_rate( dummy, move_mode_walk, 1.99 ) == normal_burn_rate + 99 );
297 CHECK( burdened_burn_rate( dummy, move_mode_walk, 2.00 ) == normal_burn_rate + 100 );
298 }
299
300 THEN( "running burn rate increases by 14 for each percent overburdened" ) {
301 CHECK( burdened_burn_rate( dummy, move_mode_run, 1.01 ) == ( normal_burn_rate + 1 ) * 14 );
302 CHECK( burdened_burn_rate( dummy, move_mode_run, 1.02 ) == ( normal_burn_rate + 2 ) * 14 );
303 CHECK( burdened_burn_rate( dummy, move_mode_run, 1.50 ) == ( normal_burn_rate + 50 ) * 14 );
304 CHECK( burdened_burn_rate( dummy, move_mode_run, 1.99 ) == ( normal_burn_rate + 99 ) * 14 );
305 CHECK( burdened_burn_rate( dummy, move_mode_run, 2.00 ) == ( normal_burn_rate + 100 ) * 14 );
306 }
307
308 THEN( "crouching burn rate increases by 1/2 for each percent overburdened" ) {
309 CHECK( burdened_burn_rate( dummy, move_mode_crouch, 1.01 ) == ( normal_burn_rate + 1 ) / 2 );
310 CHECK( burdened_burn_rate( dummy, move_mode_crouch, 1.02 ) == ( normal_burn_rate + 2 ) / 2 );
311 CHECK( burdened_burn_rate( dummy, move_mode_crouch, 1.50 ) == ( normal_burn_rate + 50 ) / 2 );
312 CHECK( burdened_burn_rate( dummy, move_mode_crouch, 1.99 ) == ( normal_burn_rate + 99 ) / 2 );
313 CHECK( burdened_burn_rate( dummy, move_mode_crouch, 2.00 ) == ( normal_burn_rate + 100 ) / 2 );
314 }
315 }
316 }
317
318 TEST_CASE( "burning stamina when overburdened may cause pain", "[stamina][burn][pain]" )
319 {
320 Character &dummy = get_player_character();
321 int pain_before;
322 int pain_after;
323
324 GIVEN( "character is severely overburdened" ) {
325
326 // As overburden percentage goes from (100% .. 350%),
327 // chance of pain goes from (1/25 .. 1/1)
328 //
329 // To guarantee pain when moving and ensure consistent test results,
330 // set to 350% burden.
331
332 clear_avatar();
333 burden_player( dummy, 3.5 );
334
335 WHEN( "they have zero stamina left" ) {
336 dummy.set_stamina( 0 );
337 REQUIRE( dummy.get_stamina() == 0 );
338
339 THEN( "they feel pain when carrying too much weight" ) {
340 pain_before = dummy.get_pain();
341 dummy.burn_move_stamina( to_moves<int>( 1_turns ) );
342 pain_after = dummy.get_pain();
343 CHECK( pain_after > pain_before );
344 }
345 }
346
347 WHEN( "they have a bad back" ) {
348 dummy.toggle_trait( trait_id( "BADBACK" ) );
349 REQUIRE( dummy.has_trait( trait_id( "BADBACK" ) ) );
350
351 THEN( "they feel pain when carrying too much weight" ) {
352 pain_before = dummy.get_pain();
353 dummy.burn_move_stamina( to_moves<int>( 1_turns ) );
354 pain_after = dummy.get_pain();
355 CHECK( pain_after > pain_before );
356 }
357 }
358 }
359 }
360
361 TEST_CASE( "stamina regeneration rate", "[stamina][update][regen]" )
362 {
363 Character &dummy = get_player_character();
364 clear_avatar();
365 int turn_moves = to_moves<int>( 1_turns );
366
367 const float normal_regen_rate = get_option<float>( "PLAYER_BASE_STAMINA_REGEN_RATE" );
368 REQUIRE( normal_regen_rate > 0 );
369
370 GIVEN( "character is not winded" ) {
371 catch_breath( dummy );
372
373 THEN( "they regain stamina at the normal rate per turn" ) {
374 CHECK( actual_regen_rate( dummy, turn_moves ) == normal_regen_rate * turn_moves );
375 }
376 }
377
378 GIVEN( "character is winded" ) {
379 dummy.add_effect( effect_winded, 10_turns );
380 REQUIRE( dummy.has_effect( effect_winded ) );
381
382 THEN( "they regain stamina at only 10%% the normal rate per turn" ) {
383 CHECK( actual_regen_rate( dummy, turn_moves ) == 0.1 * normal_regen_rate * turn_moves );
384 }
385 }
386 }
387
388 TEST_CASE( "stamina regen in different movement modes", "[stamina][update][regen][mode]" )
389 {
390 Character &dummy = get_player_character();
391 clear_avatar();
392 catch_breath( dummy );
393
394 int turn_moves = to_moves<int>( 1_turns );
395
396 dummy.set_movement_mode( move_mode_run );
397 REQUIRE( dummy.movement_mode_is( move_mode_run ) );
398 float run_regen_rate = actual_regen_rate( dummy, turn_moves );
399
400 dummy.set_movement_mode( move_mode_walk );
401 REQUIRE( dummy.movement_mode_is( move_mode_walk ) );
402 float walk_regen_rate = actual_regen_rate( dummy, turn_moves );
403
404 dummy.set_movement_mode( move_mode_crouch );
405 REQUIRE( dummy.movement_mode_is( move_mode_crouch ) );
406 float crouch_regen_rate = actual_regen_rate( dummy, turn_moves );
407
408 THEN( "run and walk mode give the same stamina regen per turn" ) {
409 CHECK( run_regen_rate == walk_regen_rate );
410 }
411
412 THEN( "walk and crouch mode give the same stamina regen per turn" ) {
413 CHECK( walk_regen_rate == crouch_regen_rate );
414 }
415
416 THEN( "crouch and run mode give the same stamina regen per turn" ) {
417 CHECK( crouch_regen_rate == run_regen_rate );
418 }
419 }
420
421 TEST_CASE( "stamina regen with mouth encumbrance", "[stamina][update][regen][encumbrance]" )
422 {
423 Character &dummy = get_player_character();
424 clear_avatar();
425 catch_breath( dummy );
426 dummy.set_body();
427
428 int turn_moves = to_moves<int>( 1_turns );
429
430 const float normal_regen_rate = get_option<float>( "PLAYER_BASE_STAMINA_REGEN_RATE" );
431 REQUIRE( normal_regen_rate > 0 );
432
433 GIVEN( "character has mouth encumbrance" ) {
434 dummy.wear_item( item( "scarf_fur" ) );
435 REQUIRE( dummy.encumb( bodypart_id( "mouth" ) ) == 10 );
436
437 THEN( "stamina regen is reduced" ) {
438 CHECK( actual_regen_rate( dummy, turn_moves ) == ( normal_regen_rate - 2 ) * turn_moves );
439
440 WHEN( "they have even more mouth encumbrance" ) {
441 // Layering two scarves triples the encumbrance
442 dummy.wear_item( item( "scarf_fur" ) );
443 REQUIRE( dummy.encumb( bodypart_id( "mouth" ) ) == 30 );
444
445 THEN( "stamina regen is reduced further" ) {
446 CHECK( actual_regen_rate( dummy, turn_moves ) == ( normal_regen_rate - 6 ) * turn_moves );
447 }
448 }
449 }
450 }
451 }
452