diff --git a/src/achievement.cpp b/src/achievement.cpp index baaf0bddc6210..d1fee25545af7 100644 --- a/src/achievement.cpp +++ b/src/achievement.cpp @@ -324,6 +324,25 @@ void achievement::check() const } } +static std::string text_for_requirement( const achievement_requirement &req, + const cata_variant ¤t_value ) +{ + bool is_satisfied = req.satisifed_by( current_value ); + nc_color c = is_satisfied ? c_green : c_yellow; + int current = current_value.get(); + int target; + std::string result; + if( req.comparison == achievement_comparison::anything ) { + target = 1; + result = string_format( _( "Triggered by " ) ); + } else { + target = req.target; + result = string_format( _( "%s/%s " ), current, target ); + } + result += req.statistic->description().translated( target ); + return colorize( result, c ); +} + class requirement_watcher : stat_watcher { public: @@ -335,6 +354,10 @@ class requirement_watcher : stat_watcher stats.add_watcher( req.statistic, this ); } + const cata_variant ¤t_value() const { + return current_value_; + } + const achievement_requirement &requirement() const { return *requirement_; } @@ -346,20 +369,7 @@ class requirement_watcher : stat_watcher } std::string ui_text() const { - bool is_satisfied = requirement_->satisifed_by( current_value_ ); - nc_color c = is_satisfied ? c_green : c_yellow; - int current = current_value_.get(); - int target; - std::string result; - if( requirement_->comparison == achievement_comparison::anything ) { - target = 1; - result = string_format( _( "Triggered by " ) ); - } else { - target = requirement_->target; - result = string_format( _( "%s/%s " ), current, target ); - } - result += requirement_->statistic->description().translated( target ); - return colorize( result, c ); + return text_for_requirement( *requirement_, current_value_ ); } private: cata_variant current_value_; @@ -369,8 +379,12 @@ class requirement_watcher : stat_watcher void requirement_watcher::new_value( const cata_variant &new_value, stats_tracker & ) { - current_value_ = new_value; - tracker_->set_requirement( this, requirement_->satisifed_by( new_value ) ); + if( !tracker_->time_is_expired() ) { + current_value_ = new_value; + } + // set_requirement can result in this being deleted, so it must be the last + // thing in this function + tracker_->set_requirement( this, requirement_->satisifed_by( current_value_ ) ); } namespace io @@ -393,6 +407,40 @@ std::string enum_to_string( achievement_completion data } // namespace io +std::string achievement_state::ui_text( const achievement *ach ) const +{ + // First: the achievement name and description + nc_color c = color_from_completion( completion ); + std::string result = colorize( ach->name(), c ) + "\n"; + if( !ach->description().empty() ) { + result += " " + colorize( ach->description(), c ) + "\n"; + } + + if( completion == achievement_completion::completed ) { + std::string message = string_format( + _( "Completed %s" ), to_string( last_state_change ) ); + result += " " + colorize( message, c ) + "\n"; + } else { + // Next: the time constraint + if( ach->time_constraint() ) { + result += " " + ach->time_constraint()->ui_text() + "\n"; + } + } + + // Next: the requirements + const std::vector &reqs = ach->requirements(); + // If these two vectors are of different sizes then the definition must + // have changed since it was complated / failed, so we don't print any + // requirements info. + if( final_values.size() == reqs.size() ) { + for( size_t i = 0; i < final_values.size(); ++i ) { + result += " " + text_for_requirement( reqs[i], final_values[i] ) + "\n"; + } + } + + return result; +} + void achievement_state::serialize( JsonOut &jsout ) const { jsout.start_object(); @@ -431,44 +479,62 @@ void achievement_tracker::set_requirement( requirement_watcher *watcher, bool is assert( sorted_watchers_[0].size() + sorted_watchers_[1].size() == watchers_.size() ); } - achievement_completion time_comp = achievement_->time_constraint() ? - achievement_->time_constraint()->completed() : achievement_completion::completed; + achievement_completion time_comp = + achievement_->time_constraint() ? + achievement_->time_constraint()->completed() : achievement_completion::completed; if( sorted_watchers_[false].empty() && time_comp == achievement_completion::completed ) { + // report_achievement can result in this being deleted, so it must be + // the last thing in the function tracker_->report_achievement( achievement_, achievement_completion::completed ); + return; } if( time_comp == achievement_completion::failed || ( !is_satisfied && watcher->requirement().becomes_false ) ) { + // report_achievement can result in this being deleted, so it must be + // the last thing in the function tracker_->report_achievement( achievement_, achievement_completion::failed ); } } -std::string achievement_tracker::ui_text( const achievement_state *state ) const +bool achievement_tracker::time_is_expired() const +{ + return achievement_->time_constraint() && + achievement_->time_constraint()->completed() == achievement_completion::failed; +} + +std::vector achievement_tracker::current_values() const +{ + std::vector result; + result.reserve( watchers_.size() ); + for( const std::unique_ptr &watcher : watchers_ ) { + result.push_back( watcher->current_value() ); + } + return result; +} + +std::string achievement_tracker::ui_text() const { // Determine overall achievement status - achievement_completion comp = state ? state->completion : achievement_completion::pending; - if( comp == achievement_completion::pending && achievement_->time_constraint() && - achievement_->time_constraint()->completed() == achievement_completion::failed ) { - comp = achievement_completion::failed; + if( time_is_expired() ) { + return achievement_state{ + achievement_completion::failed, + achievement_->time_constraint()->target(), + current_values() + }.ui_text( achievement_ ); } - // First: the achievement description - nc_color c = color_from_completion( comp ); + // First: the achievement name and description + nc_color c = color_from_completion( achievement_completion::pending ); std::string result = colorize( achievement_->name(), c ) + "\n"; if( !achievement_->description().empty() ) { result += " " + colorize( achievement_->description(), c ) + "\n"; } - if( comp == achievement_completion::completed ) { - std::string message = string_format( - _( "Completed %s" ), to_string( state->last_state_change ) ); - result += " " + colorize( message, c ) + "\n"; - } else { - // Next: the time constraint - if( achievement_->time_constraint() ) { - result += " " + achievement_->time_constraint()->ui_text() + "\n"; - } + // Next: the time constraint + if( achievement_->time_constraint() ) { + result += " " + achievement_->time_constraint()->ui_text() + "\n"; } // Next: the requirements @@ -501,31 +567,33 @@ std::vector achievements_tracker::valid_achievements() cons void achievements_tracker::report_achievement( const achievement *a, achievement_completion comp ) { - auto it = achievements_status_.find( a->id ); - achievement_completion existing_comp = - ( it == achievements_status_.end() ) ? achievement_completion::pending - : it->second.completion; - if( existing_comp == comp ) { - return; - } - achievement_state new_state{ + assert( comp != achievement_completion::pending ); + assert( !achievements_status_.count( a->id ) ); + + auto tracker_it = trackers_.find( a->id ); + achievements_status_.emplace( + a->id, + achievement_state{ comp, - calendar::turn - }; - if( it == achievements_status_.end() ) { - achievements_status_.emplace( a->id, new_state ); - } else { - it->second = new_state; + calendar::turn, + tracker_it->second.current_values() } + ); if( comp == achievement_completion::completed ) { achievement_attained_callback_( a ); } + trackers_.erase( tracker_it ); } achievement_completion achievements_tracker::is_completed( const string_id &id ) const { auto it = achievements_status_.find( id ); if( it == achievements_status_.end() ) { + // It might still have failed; check for time expiry + auto tracker_it = trackers_.find( id ); + if( tracker_it != trackers_.end() && tracker_it->second.time_is_expired() ) { + return achievement_completion::failed; + } return achievement_completion::pending; } return it->second.completion; @@ -534,21 +602,20 @@ achievement_completion achievements_tracker::is_completed( const string_idid ); - const achievement_state *state = nullptr; if( state_it != achievements_status_.end() ) { - state = &state_it->second; + return state_it->second.ui_text( ach ); } - auto watcher_it = watchers_.find( ach->id ); - if( watcher_it == watchers_.end() ) { + auto tracker_it = trackers_.find( ach->id ); + if( tracker_it == trackers_.end() ) { return colorize( ach->description() + _( "\nInternal error: achievement lacks watcher." ), c_red ); } - return watcher_it->second.ui_text( state ); + return tracker_it->second.ui_text(); } void achievements_tracker::clear() { - watchers_.clear(); + trackers_.clear(); initial_achievements_.clear(); achievements_status_.clear(); } @@ -584,7 +651,10 @@ void achievements_tracker::deserialize( JsonIn &jsin ) void achievements_tracker::init_watchers() { for( const achievement *a : valid_achievements() ) { - watchers_.emplace( + if( achievements_status_.count( a->id ) ) { + continue; + } + trackers_.emplace( std::piecewise_construct, std::forward_as_tuple( a->id ), std::forward_as_tuple( *a, *this, *stats_ ) ); } diff --git a/src/achievement.h b/src/achievement.h index 7d3967a7a7484..bd7b47ab2a4a5 100644 --- a/src/achievement.h +++ b/src/achievement.h @@ -107,10 +107,20 @@ struct enum_traits { static constexpr achievement::time_bound::epoch last = achievement::time_bound::epoch::last; }; +// Once an achievement is either completed or failed it is stored as an +// achievement_state struct achievement_state { + // The final state achievement_completion completion; + + // When it became that state time_point last_state_change; + // The values for each requirement at the time of completion or failure + std::vector final_values; + + std::string ui_text( const achievement * ) const; + void serialize( JsonOut & ) const; void deserialize( JsonIn & ); }; @@ -127,7 +137,9 @@ class achievement_tracker void set_requirement( requirement_watcher *watcher, bool is_satisfied ); - std::string ui_text( const achievement_state * ) const; + bool time_is_expired() const; + std::vector current_values() const; + std::string ui_text() const; private: const achievement *achievement_; achievements_tracker *tracker_; @@ -147,8 +159,9 @@ class achievements_tracker : public event_subscriber achievements_tracker( const achievements_tracker & ) = delete; achievements_tracker &operator=( const achievements_tracker & ) = delete; - achievements_tracker( stats_tracker &, - const std::function &achievement_attained_callback ); + achievements_tracker( + stats_tracker &, + const std::function &achievement_attained_callback ); ~achievements_tracker() override; // Return all scores which are valid now and existed at game start @@ -169,8 +182,11 @@ class achievements_tracker : public event_subscriber stats_tracker *stats_ = nullptr; std::function achievement_attained_callback_; - std::unordered_map, achievement_tracker> watchers_; std::unordered_set> initial_achievements_; + + // Class invariant: each valid achievement has exactly one of a watcher + // (if it's pending) or a status (if it's completed or failed). + std::unordered_map, achievement_tracker> trackers_; std::unordered_map, achievement_state> achievements_status_; }; diff --git a/tests/stats_tracker_test.cpp b/tests/stats_tracker_test.cpp index 6ccb03fb46b78..c03ffbe08bf42 100644 --- a/tests/stats_tracker_test.cpp +++ b/tests/stats_tracker_test.cpp @@ -537,7 +537,32 @@ TEST_CASE( "achievments_tracker", "[stats]" ) CHECK( a.ui_text_for( &*a_kill_in_first_minute ) == "Rude awakening\n" " Within 1 minute of start of game (passed)\n" + " 0/1 monster killed\n" ); + } + + // Advance a minute and kill again + calendar::turn += 1_minutes; + b.send( avatar_zombie_kill ); + + if( time_since_game_start < 1_minutes ) { + CHECK( a.ui_text_for( achievements_completed.at( a_kill_zombie ) ) == + "One down, billions to go…\n" + " Completed Year 1, Spring, day 1 0000.30\n" + " 1/1 zombie killed\n" ); + CHECK( a.ui_text_for( achievements_completed.at( a_kill_in_first_minute ) ) == + "Rude awakening\n" + " Completed Year 1, Spring, day 1 0000.30\n" " 1/1 monster killed\n" ); + } else { + CHECK( a.ui_text_for( achievements_completed.at( a_kill_zombie ) ) == + "One down, billions to go…\n" + " Completed Year 1, Spring, day 1 0010.00\n" + " 1/1 zombie killed\n" ); + CHECK( !achievements_completed.count( a_kill_in_first_minute ) ); + CHECK( a.ui_text_for( &*a_kill_in_first_minute ) == + "Rude awakening\n" + " Within 1 minute of start of game (passed)\n" + " 0/1 monster killed\n" ); } }