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