From 3db92839b578d2ad7a5317a56fe27aeaca9b8082 Mon Sep 17 00:00:00 2001 From: John Bytheway Date: Wed, 15 Apr 2020 20:21:22 -0400 Subject: [PATCH 1/7] Make score descriptions optional It makes more sense to name the statistics, since both scores and achievements are based on them, and we would only end up writing the same information twice. So, if a score has no description, derive one from its statistic. --- data/json/scores.json | 4 ++-- src/event_statistics.cpp | 10 ++++++++-- tests/stats_tracker_test.cpp | 2 ++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/data/json/scores.json b/data/json/scores.json index 961089f99bc72..6953d7be39d01 100644 --- a/data/json/scores.json +++ b/data/json/scores.json @@ -30,7 +30,8 @@ "id": "num_avatar_kills", "type": "event_statistic", "stat_type": "count", - "event_transformation": "avatar_kills" + "event_transformation": "avatar_kills", + "description": "Number of monsters killed" }, { "id": "num_avatar_zombie_kills", @@ -42,7 +43,6 @@ { "id": "score_kills", "type": "score", - "description": "Number of monsters killed: %s", "statistic": "num_avatar_kills" }, { diff --git a/src/event_statistics.cpp b/src/event_statistics.cpp index 300e309d1d253..d347c9b0b5c30 100644 --- a/src/event_statistics.cpp +++ b/src/event_statistics.cpp @@ -642,7 +642,13 @@ void event_statistic::check() const std::string score::description( stats_tracker &stats ) const { - return string_format( description_.translated(), value( stats ).get_string() ); + std::string value_string = value( stats ).get_string(); + if( description_.empty() ) { + //~ Default format for scores. %1$s is statistic description; %2$s is value. + return string_format( _( "%1$s: %2$s" ), this->stat_->description(), value_string ); + } else { + return string_format( description_.translated(), value_string ); + } } cata_variant score::value( stats_tracker &stats ) const @@ -652,7 +658,7 @@ cata_variant score::value( stats_tracker &stats ) const void score::load( const JsonObject &jo, const std::string & ) { - mandatory( jo, was_loaded, "description", description_ ); + optional( jo, was_loaded, "description", description_ ); mandatory( jo, was_loaded, "statistic", stat_ ); } diff --git a/tests/stats_tracker_test.cpp b/tests/stats_tracker_test.cpp index d2b17142dfafc..32b0da912aec4 100644 --- a/tests/stats_tracker_test.cpp +++ b/tests/stats_tracker_test.cpp @@ -112,10 +112,12 @@ TEST_CASE( "stats_tracker_with_event_statistics", "[stats]" ) b.send( u_id ); CHECK( avatar_id->value( s ) == cata_variant( u_id ) ); CHECK( score_kills->value( s ).get() == 0 ); + CHECK( score_kills->description( s ) == "Number of monsters killed: 0" ); b.send( avatar_zombie_kill ); CHECK( num_avatar_kills->value( s ).get() == 1 ); CHECK( num_avatar_zombie_kills->value( s ).get() == 1 ); CHECK( score_kills->value( s ).get() == 1 ); + CHECK( score_kills->description( s ) == "Number of monsters killed: 1" ); b.send( avatar_dog_kill ); CHECK( num_avatar_kills->value( s ).get() == 2 ); CHECK( num_avatar_zombie_kills->value( s ).get() == 1 ); From dddbcc9314e2c1adc158b4f2d6b515b27c309688 Mon Sep 17 00:00:00 2001 From: John Bytheway Date: Wed, 15 Apr 2020 21:43:27 -0400 Subject: [PATCH 2/7] Track game start time Currently we track the time of the cataclysm, but not the time of the game starting. This is needed for achievements (and potentially other things, like missions) so add it. In legacy saves, it will default to the same time as the cataclysm. --- src/calendar.cpp | 1 + src/calendar.h | 1 + src/game.cpp | 17 +++++++++-------- src/savegame.cpp | 3 +++ 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/calendar.cpp b/src/calendar.cpp index 0487a74ae3b5b..d3c7ec65877fa 100644 --- a/src/calendar.cpp +++ b/src/calendar.cpp @@ -27,6 +27,7 @@ const time_point calendar::before_time_starts = time_point::from_turn( -1 ); const time_point calendar::turn_zero = time_point::from_turn( 0 ); time_point calendar::start_of_cataclysm = calendar::turn_zero; +time_point calendar::start_of_game = calendar::turn_zero; time_point calendar::turn = calendar::turn_zero; season_type calendar::initial_season = SPRING; diff --git a/src/calendar.h b/src/calendar.h index 9221563618531..262a95675740d 100644 --- a/src/calendar.h +++ b/src/calendar.h @@ -105,6 +105,7 @@ float season_from_default_ratio(); std::string name_season( season_type s ); extern time_point start_of_cataclysm; +extern time_point start_of_game; extern time_point turn; extern season_type initial_season; diff --git a/src/game.cpp b/src/game.cpp index da75031fe5502..e1dd4bb73677d 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -11555,21 +11555,21 @@ void game::start_calendar() if( scen_season ) { // Configured starting date overridden by scenario, calendar::start is left as Spring 1 calendar::start_of_cataclysm = calendar::turn_zero + 1_hours * get_option( "INITIAL_TIME" ); - calendar::turn = calendar::turn_zero + 1_hours * get_option( "INITIAL_TIME" ); + calendar::start_of_game = calendar::turn_zero + 1_hours * get_option( "INITIAL_TIME" ); if( scen->has_flag( "SPR_START" ) ) { calendar::initial_season = SPRING; } else if( scen->has_flag( "SUM_START" ) ) { calendar::initial_season = SUMMER; - calendar::turn += calendar::season_length(); + calendar::start_of_game += calendar::season_length(); } else if( scen->has_flag( "AUT_START" ) ) { calendar::initial_season = AUTUMN; - calendar::turn += calendar::season_length() * 2; + calendar::start_of_game += calendar::season_length() * 2; } else if( scen->has_flag( "WIN_START" ) ) { calendar::initial_season = WINTER; - calendar::turn += calendar::season_length() * 3; + calendar::start_of_game += calendar::season_length() * 3; } else if( scen->has_flag( "SUM_ADV_START" ) ) { calendar::initial_season = SUMMER; - calendar::turn += calendar::season_length() * 5; + calendar::start_of_game += calendar::season_length() * 5; } else { debugmsg( "The Unicorn" ); } @@ -11595,11 +11595,12 @@ void game::start_calendar() calendar::initial_season = WINTER; } - calendar::turn = calendar::start_of_cataclysm - + 1_hours * get_option( "INITIAL_TIME" ) - + 1_days * get_option( "SPAWN_DELAY" ); + calendar::start_of_game = calendar::start_of_cataclysm + + 1_hours * get_option( "INITIAL_TIME" ) + + 1_days * get_option( "SPAWN_DELAY" ); } + calendar::turn = calendar::start_of_game; } void game::add_artifact_messages( const std::vector &effects ) diff --git a/src/savegame.cpp b/src/savegame.cpp index 627187868bd1c..25c73eca32ccf 100644 --- a/src/savegame.cpp +++ b/src/savegame.cpp @@ -80,6 +80,7 @@ void game::serialize( std::ostream &fout ) // basic game state information. json.member( "turn", calendar::turn ); json.member( "calendar_start", calendar::start_of_cataclysm ); + json.member( "game_start", calendar::start_of_game ); json.member( "initial_season", static_cast( calendar::initial_season ) ); json.member( "auto_travel_mode", auto_travel_mode ); json.member( "run_mode", static_cast( safe_mode ) ); @@ -194,6 +195,8 @@ void game::unserialize( std::istream &fin ) calendar::turn = tmpturn; calendar::start_of_cataclysm = tmpcalstart; + data.read( "game_start", calendar::start_of_game, calendar::start_of_cataclysm ); + load_map( tripoint( levx + comx * OMAPX * 2, levy + comy * OMAPY * 2, levz ) ); safe_mode = static_cast( tmprun ); From e6e6d9e13c2097eb7bb8c4cac4a39ae229d215e7 Mon Sep 17 00:00:00 2001 From: John Bytheway Date: Wed, 15 Apr 2020 21:45:22 -0400 Subject: [PATCH 3/7] Add achievement_comparison::less_equal Add new enumerator, and move into header. --- src/achievement.cpp | 13 +++---------- src/achievement.h | 11 +++++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/achievement.cpp b/src/achievement.cpp index e2a12ccc22ca9..e78d50ce59430 100644 --- a/src/achievement.cpp +++ b/src/achievement.cpp @@ -42,11 +42,6 @@ generic_factory achievement_factory( "achievement" ); } // namespace -enum class achievement_comparison { - greater_equal, - last, -}; - namespace io { @@ -55,6 +50,7 @@ std::string enum_to_string( achievement_comparison data { switch( data ) { // *INDENT-OFF* + case achievement_comparison::less_equal: return "<="; case achievement_comparison::greater_equal: return ">="; // *INDENT-ON* case achievement_comparison::last: @@ -66,11 +62,6 @@ std::string enum_to_string( achievement_comparison data } // namespace io -template<> -struct enum_traits { - static constexpr achievement_comparison last = achievement_comparison::last; -}; - struct achievement_requirement { string_id statistic; achievement_comparison comparison; @@ -94,6 +85,8 @@ struct achievement_requirement { bool satisifed_by( const cata_variant &v ) const { int value = v.get(); switch( comparison ) { + case achievement_comparison::less_equal: + return value <= target; case achievement_comparison::greater_equal: return value >= target; case achievement_comparison::last: diff --git a/src/achievement.h b/src/achievement.h index 52edbd9a7ff1b..7b3810266c8f4 100644 --- a/src/achievement.h +++ b/src/achievement.h @@ -18,6 +18,17 @@ class achievements_tracker; class requirement_watcher; class stats_tracker; +enum class achievement_comparison { + less_equal, + greater_equal, + last, +}; + +template<> +struct enum_traits { + static constexpr achievement_comparison last = achievement_comparison::last; +}; + class achievement { public: From dfbae57185f175fd7fa3382bddcefd984795e9d3 Mon Sep 17 00:00:00 2001 From: John Bytheway Date: Fri, 17 Apr 2020 08:38:26 -0400 Subject: [PATCH 4/7] Add achievement time constraints Allow for achievements to specify a minimum or maximum time bound within which they must be completed. Test this with a new "kill within the first minute" achievement. With this change, achievements can now become "failed", meaning that it is impossible to ever achieve them in this game. Failed achievements get sorted to the bottom of the list, and get a new colour scheme. As a side-effect, other requirements can also get a "<=" constraint, which also means they can become impossible to satisfy, and can lead to achievements being failed. To support this we need to track which statistics are monotonic, so we know when such failures are irreversible. --- data/json/achievements.json | 15 +++ data/json/scores.json | 6 - src/achievement.cpp | 228 ++++++++++++++++++++++++++++++++--- src/achievement.h | 49 ++++++-- src/enums.h | 17 +++ src/event_statistics.cpp | 51 ++++++++ src/event_statistics.h | 8 +- src/init.cpp | 1 + src/savegame.cpp | 4 +- src/stats_tracker.h | 1 + tests/stats_tracker_test.cpp | 56 +++++++-- 11 files changed, 390 insertions(+), 46 deletions(-) create mode 100644 data/json/achievements.json diff --git a/data/json/achievements.json b/data/json/achievements.json new file mode 100644 index 0000000000000..52e6cd3563d7f --- /dev/null +++ b/data/json/achievements.json @@ -0,0 +1,15 @@ +[ + { + "id": "achievement_kill_zombie", + "type": "achievement", + "description": "One down, billions to go\u2026", + "requirements": [ { "event_statistic": "num_avatar_zombie_kills", "is": ">=", "target": 1 } ] + }, + { + "id": "achievement_kill_in_first_minute", + "type": "achievement", + "description": "Rude awakening", + "time_constraint": { "since": "game_start", "is": "<=", "target": "1 minute" }, + "requirements": [ { "event_statistic": "num_avatar_kills", "is": ">=", "target": 1 } ] + } +] diff --git a/data/json/scores.json b/data/json/scores.json index 6953d7be39d01..fb0565d91471a 100644 --- a/data/json/scores.json +++ b/data/json/scores.json @@ -45,12 +45,6 @@ "type": "score", "statistic": "num_avatar_kills" }, - { - "id": "achievement_kill_zombie", - "type": "achievement", - "description": "One down, billions to go\u2026", - "requirements": [ { "event_statistic": "num_avatar_zombie_kills", "is": ">=", "target": 1 } ] - }, { "id": "moves_not_mounted", "type": "event_transformation", diff --git a/src/achievement.cpp b/src/achievement.cpp index e78d50ce59430..452743d9bc93f 100644 --- a/src/achievement.cpp +++ b/src/achievement.cpp @@ -1,6 +1,7 @@ #include "achievement.h" #include "avatar.h" +#include "enums.h" #include "event_statistics.h" #include "generic_factory.h" #include "stats_tracker.h" @@ -42,6 +43,13 @@ generic_factory achievement_factory( "achievement" ); } // namespace +/** @relates string_id */ +template<> +const achievement &string_id::obj() const +{ + return achievement_factory.obj( *this ); +} + namespace io { @@ -60,12 +68,44 @@ std::string enum_to_string( achievement_comparison data abort(); } +template<> +std::string enum_to_string( achievement::time_bound::epoch data ) +{ + switch( data ) { + // *INDENT-OFF* + case achievement::time_bound::epoch::cataclysm: return "cataclysm"; + case achievement::time_bound::epoch::game_start: return "game_start"; + // *INDENT-ON* + case achievement::time_bound::epoch::last: + break; + } + debugmsg( "Invalid epoch" ); + abort(); +} + } // namespace io +static nc_color color_from_completion( achievement_completion comp ) +{ + switch( comp ) { + case achievement_completion::pending: + return c_yellow; + case achievement_completion::completed: + return c_light_green; + case achievement_completion::failed: + return c_light_gray; + case achievement_completion::last: + break; + } + debugmsg( "Invalid achievement_completion" ); + abort(); +} + struct achievement_requirement { string_id statistic; achievement_comparison comparison; int target; + bool becomes_false; void deserialize( JsonIn &jin ) { const JsonObject &jo = jin.get_object(); @@ -76,6 +116,21 @@ struct achievement_requirement { } } + void finalize() { + switch( comparison ) { + case achievement_comparison::less_equal: + becomes_false = is_increasing( statistic->monotonicity() ); + return; + case achievement_comparison::greater_equal: + becomes_false = is_decreasing( statistic->monotonicity() ); + return; + case achievement_comparison::last: + break; + } + debugmsg( "Invalid achievement_comparison" ); + abort(); + } + void check( const string_id &id ) const { if( !statistic.is_valid() ) { debugmsg( "score %s refers to invalid statistic %s", id.str(), statistic.str() ); @@ -97,12 +152,126 @@ struct achievement_requirement { } }; +static time_point epoch_to_time_point( achievement::time_bound::epoch e ) +{ + switch( e ) { + case achievement::time_bound::epoch::cataclysm: + return calendar::start_of_cataclysm; + case achievement::time_bound::epoch::game_start: + return calendar::start_of_game; + case achievement::time_bound::epoch::last: + break; + } + debugmsg( "Invalid epoch" ); + abort(); +} + +void achievement::time_bound::deserialize( JsonIn &jin ) +{ + const JsonObject &jo = jin.get_object(); + if( !( jo.read( "since", epoch_ ) && + jo.read( "is", comparison_ ) && + jo.read( "target", period_ ) ) ) { + jo.throw_error( "Mandatory field missing for achievement time_constaint" ); + } +} + +time_point achievement::time_bound::target() const +{ + return epoch_to_time_point( epoch_ ) + period_; +} + +achievement_completion achievement::time_bound::completed() const +{ + time_point now = calendar::turn; + switch( comparison_ ) { + case achievement_comparison::less_equal: + if( now <= target() ) { + return achievement_completion::completed; + } else { + return achievement_completion::failed; + } + case achievement_comparison::greater_equal: + if( now >= target() ) { + return achievement_completion::completed; + } else { + return achievement_completion::pending; + } + case achievement_comparison::last: + break; + } + debugmsg( "Invalid achievement_comparison" ); + abort(); +} + +std::string achievement::time_bound::ui_text() const +{ + time_point now = calendar::turn; + achievement_completion comp = completed(); + + nc_color c = color_from_completion( comp ); + + auto translate_epoch = []( epoch e ) { + switch( e ) { + case epoch::cataclysm: + return _( "time of cataclysm" ); + case epoch::game_start: + return _( "start of game" ); + case epoch::last: + break; + } + debugmsg( "Invalid epoch" ); + abort(); + }; + + std::string message = [&]() { + switch( comp ) { + case achievement_completion::pending: + return string_format( _( "At least %s from %s (%s remaining)" ), + to_string( period_ ), translate_epoch( epoch_ ), + to_string( target() - now ) ); + case achievement_completion::completed: + switch( comparison_ ) { + case achievement_comparison::less_equal: + return string_format( _( "Within %s of %s (%s remaining)" ), + to_string( period_ ), translate_epoch( epoch_ ), + to_string( target() - now ) ); + case achievement_comparison::greater_equal: + return string_format( _( "At least %s from %s (passed)" ), + to_string( period_ ), translate_epoch( epoch_ ) ); + case achievement_comparison::last: + break; + } + debugmsg( "Invalid achievement_comparison" ); + abort(); + case achievement_completion::failed: + return string_format( _( "Within %s of %s (passed)" ), + to_string( period_ ), translate_epoch( epoch_ ) ); + case achievement_completion::last: + break; + } + debugmsg( "Invalid achievement_completion" ); + abort(); + } + (); + + return colorize( message, c ); +} void achievement::load_achievement( const JsonObject &jo, const std::string &src ) { achievement_factory.load( jo, src ); } +void achievement::finalize() +{ + for( achievement &a : const_cast&>( achievement::get_all() ) ) { + for( achievement_requirement &req : a.requirements_ ) { + req.finalize(); + } + } +} + void achievement::check_consistency() { achievement_factory.check(); @@ -121,6 +290,7 @@ void achievement::reset() void achievement::load( const JsonObject &jo, const std::string & ) { mandatory( jo, was_loaded, "description", description_ ); + optional( jo, was_loaded, "time_constraint", time_constraint_ ); mandatory( jo, was_loaded, "requirements", requirements_ ); } @@ -142,6 +312,10 @@ class requirement_watcher : stat_watcher stats.add_watcher( req.statistic, this ); } + const achievement_requirement &requirement() const { + return *requirement_; + } + void new_value( const cata_variant &new_value, stats_tracker & ) override; bool is_satisfied( stats_tracker &stats ) { @@ -177,6 +351,7 @@ std::string enum_to_string( achievement_completion data // *INDENT-OFF* case achievement_completion::pending: return "pending"; case achievement_completion::completed: return "completed"; + case achievement_completion::failed: return "failed"; // *INDENT-ON* case achievement_completion::last: break; @@ -219,41 +394,54 @@ achievement_tracker::achievement_tracker( const achievement &a, achievements_tra void achievement_tracker::set_requirement( requirement_watcher *watcher, bool is_satisfied ) { - if( !sorted_watchers_[is_satisfied].insert( watcher ).second ) { - // No change - return; + if( sorted_watchers_[is_satisfied].insert( watcher ).second ) { + // Remove from other; check for completion. + sorted_watchers_[!is_satisfied].erase( watcher ); + assert( sorted_watchers_[0].size() + sorted_watchers_[1].size() == watchers_.size() ); } - // Remove from other; check for completion. - sorted_watchers_[!is_satisfied].erase( watcher ); - 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; - if( sorted_watchers_[false].empty() ) { + if( sorted_watchers_[false].empty() && time_comp == achievement_completion::completed ) { tracker_->report_achievement( achievement_, achievement_completion::completed ); } + + if( time_comp == achievement_completion::failed || + ( !is_satisfied && watcher->requirement().becomes_false ) ) { + tracker_->report_achievement( achievement_, achievement_completion::failed ); + } } std::string achievement_tracker::ui_text( const achievement_state *state ) const { - auto color_from_completion = []( achievement_completion comp ) { - switch( comp ) { - case achievement_completion::pending: - return c_yellow; - case achievement_completion::completed: - return c_light_green; - case achievement_completion::last: - break; - } - debugmsg( "Invalid achievement_completion" ); - abort(); - }; - + // 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; + } + + // First: the achievement description nc_color c = color_from_completion( comp ); std::string 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 requirements for( const std::unique_ptr &watcher : watchers_ ) { result += " " + watcher->ui_text() + "\n"; } + return result; } diff --git a/src/achievement.h b/src/achievement.h index 7b3810266c8f4..4d2d1b87074a3 100644 --- a/src/achievement.h +++ b/src/achievement.h @@ -9,6 +9,7 @@ #include #include "event_bus.h" +#include "optional.h" #include "string_id.h" #include "translations.h" @@ -29,6 +30,18 @@ struct enum_traits { static constexpr achievement_comparison last = achievement_comparison::last; }; +enum class achievement_completion { + pending, + completed, + failed, + last +}; + +template<> +struct enum_traits { + static constexpr achievement_completion last = achievement_completion::last; +}; + class achievement { public: @@ -37,6 +50,7 @@ class achievement void load( const JsonObject &, const std::string & ); void check() const; static void load_achievement( const JsonObject &, const std::string & ); + static void finalize(); static void check_consistency(); static const std::vector &get_all(); static void reset(); @@ -48,23 +62,42 @@ class achievement return description_; } + class time_bound + { + public: + enum class epoch { + cataclysm, + game_start, + last + }; + + void deserialize( JsonIn & ); + + time_point target() const; + achievement_completion completed() const; + std::string ui_text() const; + private: + achievement_comparison comparison_; + epoch epoch_; + time_duration period_; + }; + + const cata::optional &time_constraint() const { + return time_constraint_; + } + const std::vector &requirements() const { return requirements_; } private: translation description_; + cata::optional time_constraint_; std::vector requirements_; }; -enum class achievement_completion { - pending, - completed, - last -}; - template<> -struct enum_traits { - static constexpr achievement_completion last = achievement_completion::last; +struct enum_traits { + static constexpr achievement::time_bound::epoch last = achievement::time_bound::epoch::last; }; struct achievement_state { diff --git a/src/enums.h b/src/enums.h index b33114cfeaee2..b8ae393b3c3a4 100644 --- a/src/enums.h +++ b/src/enums.h @@ -282,4 +282,21 @@ struct game_message_params { game_message_flags flags; }; +enum class monotonically : int { + constant, + increasing, + decreasing, + unknown, +}; + +constexpr bool is_increasing( monotonically m ) +{ + return m == monotonically::constant || m == monotonically::increasing; +} + +constexpr bool is_decreasing( monotonically m ) +{ + return m == monotonically::constant || m == monotonically::decreasing; +} + #endif // CATA_SRC_ENUMS_H diff --git a/src/event_statistics.cpp b/src/event_statistics.cpp index d347c9b0b5c30..28e57c5aa25b9 100644 --- a/src/event_statistics.cpp +++ b/src/event_statistics.cpp @@ -8,6 +8,7 @@ #include "cata_variant.h" #include "debug.h" +#include "enums.h" #include "event.h" #include "event_field_transformations.h" #include "generic_factory.h" @@ -144,6 +145,7 @@ class event_transformation::impl virtual event_multiset initialize( stats_tracker & ) const = 0; virtual std::unique_ptr watch( stats_tracker & ) const = 0; virtual void check( const std::string &/*name*/ ) const {} + virtual monotonically monotonicity() const = 0; virtual std::unique_ptr clone() const = 0; }; @@ -154,6 +156,7 @@ class event_statistic::impl virtual cata_variant value( stats_tracker & ) const = 0; virtual std::unique_ptr watch( stats_tracker & ) const = 0; virtual void check( const std::string &/*name*/ ) const {} + virtual monotonically monotonicity() const = 0; virtual std::unique_ptr clone() const = 0; }; @@ -203,6 +206,11 @@ struct value_constraint { name, equals_statistic_->str() ); } } + + bool is_constant() const { + return !equals_statistic_ || + ( *equals_statistic_ )->monotonicity() == monotonically::constant; + } }; struct new_field { @@ -277,6 +285,14 @@ struct event_source { stats.add_watcher( transformation, watcher ); } } + + monotonically monotonicity() const { + if( transformation.is_empty() ) { + return monotonically::increasing; + } else { + return transformation->monotonicity(); + } + } }; struct event_transformation_impl : public event_transformation::impl { @@ -398,6 +414,15 @@ struct event_transformation_impl : public event_transformation::impl { } } + monotonically monotonicity() const override { + for( const std::pair &constraint : constraints_ ) { + if( !constraint.second.is_constant() ) { + return monotonically::unknown; + } + } + return monotonically::increasing; + } + std::unique_ptr clone() const override { return std::make_unique( *this ); } @@ -435,6 +460,11 @@ void event_transformation::check() const impl_->check( id.str() ); } +monotonically event_transformation::monotonicity() const +{ + return impl_->monotonicity(); +} + struct event_statistic_count : event_statistic::impl { event_statistic_count( const string_id &i, const event_source &s ) : id( i ), @@ -474,6 +504,10 @@ struct event_statistic_count : event_statistic::impl { return std::make_unique( this, stats ); } + monotonically monotonicity() const override { + return source.monotonicity(); + } + std::unique_ptr clone() const override { return std::make_unique( *this ); } @@ -519,6 +553,10 @@ struct event_statistic_total : event_statistic::impl { return std::make_unique( this, stats ); } + monotonically monotonicity() const override { + return source.monotonicity(); + } + std::unique_ptr clone() const override { return std::make_unique( *this ); } @@ -595,6 +633,14 @@ struct event_statistic_unique_value : event_statistic::impl { } } + monotonically monotonicity() const override { + if( type_ == event_type::game_start ) { + return monotonically::constant; + } else { + return monotonically::unknown; + } + } + std::unique_ptr clone() const override { return std::make_unique( *this ); } @@ -640,6 +686,11 @@ void event_statistic::check() const impl_->check( id.str() ); } +monotonically event_statistic::monotonicity() const +{ + return impl_->monotonicity(); +} + std::string score::description( stats_tracker &stats ) const { std::string value_string = value( stats ).get_string(); diff --git a/src/event_statistics.h b/src/event_statistics.h index 1a93759e18816..bae47d0676f08 100644 --- a/src/event_statistics.h +++ b/src/event_statistics.h @@ -12,9 +12,9 @@ class cata_variant; class event_multiset; - enum class event_type : int; class JsonObject; +enum class monotonically : int; class stats_tracker; class stats_tracker_state; @@ -48,8 +48,9 @@ class event_transformation string_id id; bool was_loaded = false; - class impl; + monotonically monotonicity() const; + class impl; private: cata::clone_ptr impl_; }; @@ -74,8 +75,9 @@ class event_statistic return description_; } - class impl; + monotonically monotonicity() const; + class impl; private: std::string description_; cata::clone_ptr impl_; diff --git a/src/init.cpp b/src/init.cpp index 20b849b257e97..aa97399b02dba 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -616,6 +616,7 @@ void DynamicDataLoader::finalize_loaded_data( loading_ui &ui ) { _( "Harvest lists" ), &harvest_list::finalize_all }, { _( "Anatomies" ), &anatomy::finalize_all }, { _( "Mutations" ), &mutation_branch::finalize }, + { _( "Achivements" ), &achievement::finalize }, #if defined(TILES) { _( "Tileset" ), &load_tileset }, #endif diff --git a/src/savegame.cpp b/src/savegame.cpp index 25c73eca32ccf..6a02e55e10c6f 100644 --- a/src/savegame.cpp +++ b/src/savegame.cpp @@ -195,7 +195,9 @@ void game::unserialize( std::istream &fin ) calendar::turn = tmpturn; calendar::start_of_cataclysm = tmpcalstart; - data.read( "game_start", calendar::start_of_game, calendar::start_of_cataclysm ); + if( !data.read( "game_start", calendar::start_of_game ) ) { + calendar::start_of_game = calendar::start_of_cataclysm; + } load_map( tripoint( levx + comx * OMAPX * 2, levy + comy * OMAPY * 2, levz ) ); diff --git a/src/stats_tracker.h b/src/stats_tracker.h index 748820a03dd9e..7941d26ecf0d2 100644 --- a/src/stats_tracker.h +++ b/src/stats_tracker.h @@ -17,6 +17,7 @@ class JsonIn; class JsonOut; class event_statistic; class event_transformation; +enum class monotonically : int; class score; class stats_tracker; diff --git a/tests/stats_tracker_test.cpp b/tests/stats_tracker_test.cpp index 32b0da912aec4..6f549a2708134 100644 --- a/tests/stats_tracker_test.cpp +++ b/tests/stats_tracker_test.cpp @@ -11,6 +11,7 @@ #include "game.h" #include "stats_tracker.h" #include "string_id.h" +#include "stringmaker.h" #include "type_id.h" TEST_CASE( "stats_tracker_count_events", "[stats]" ) @@ -227,29 +228,68 @@ TEST_CASE( "stats_tracker_watchers", "[stats]" ) TEST_CASE( "achievments_tracker", "[stats]" ) { - const achievement *achievement_completed = nullptr; + std::map, const achievement *> achievements_completed; event_bus b; stats_tracker s; b.subscribe( &s ); achievements_tracker a( s, [&]( const achievement * a ) { - achievement_completed = a; + achievements_completed.emplace( a->id, a ); } ); b.subscribe( &a ); SECTION( "kills" ) { + time_duration time_since_game_start = GENERATE( 30_seconds, 10_minutes ); + CAPTURE( time_since_game_start ); + calendar::turn = calendar::start_of_game + time_since_game_start; + const character_id u_id = g->u.getID(); const mtype_id mon_zombie( "mon_zombie" ); const cata::event avatar_zombie_kill = cata::event::make( u_id, mon_zombie ); + string_id a_kill_zombie( "achievement_kill_zombie" ); + string_id a_kill_in_first_minute( "achievement_kill_in_first_minute" ); + b.send( u_id ); - CHECK( achievement_completed == nullptr ); + + CHECK( a.ui_text_for( &*a_kill_zombie ) == + "One down, billions to go…\n" + " 0/1 Number of zombies killed\n" ); + if( time_since_game_start < 1_minutes ) { + CHECK( a.ui_text_for( &*a_kill_in_first_minute ) == + "Rude awakening\n" + " Within 1 minute of start of game (30 seconds remaining)\n" + " 0/1 Number of monsters killed\n" ); + } else { + CHECK( a.ui_text_for( &*a_kill_in_first_minute ) == + "Rude awakening\n" + " Within 1 minute of start of game (passed)\n" + " 0/1 Number of monsters killed\n" ); + } + + CHECK( achievements_completed.empty() ); b.send( avatar_zombie_kill ); - REQUIRE( achievement_completed != nullptr ); - CHECK( achievement_completed->id.str() == "achievement_kill_zombie" ); - CHECK( a.ui_text_for( achievement_completed ) == - "One down, billions to go…\n" - " 1/1 Number of zombies killed\n" ); + + 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 0 0000.30\n" + " 1/1 Number of zombies killed\n" ); + CHECK( a.ui_text_for( achievements_completed.at( a_kill_in_first_minute ) ) == + "Rude awakening\n" + " Completed Year 1, Spring, day 0 0000.30\n" + " 1/1 Number of monsters 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 0 0010.00\n" + " 1/1 Number of zombies 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" + " 1/1 Number of monsters killed\n" ); + } } } From 28a970f27d4c4cbd75ce636d94449214987d7df5 Mon Sep 17 00:00:00 2001 From: John Bytheway Date: Sat, 18 Apr 2020 08:33:27 -0400 Subject: [PATCH 5/7] Update docs for new achievements features --- doc/JSON_INFO.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/doc/JSON_INFO.md b/doc/JSON_INFO.md index 395394331dd6c..0dbbeabb80fd8 100644 --- a/doc/JSON_INFO.md +++ b/doc/JSON_INFO.md @@ -1181,7 +1181,8 @@ given field for that unique event: Regardless of `stat_type`, each `event_statistic` can also have: ```C++ -"description": "Number of things" // Intended for use in describing achievement requirements. +// Intended for use in describing scores and achievement requirements. +"description": "Number of things" ``` #### `score` @@ -1193,6 +1194,9 @@ of scores. The `description` specifies a string which is expected to contain a Note that even though most statistics yield an integer, you should still use `%s`. +If the underlying statistic has a description, then the score description is +optional. It defaults to ": ". + ```C++ "id": "score_headshots", "type": "score", @@ -1222,6 +1226,26 @@ an `event_statistic`. For example: }, ``` +Currently the `"is"` field must be `">="` or `"<="` and the `"target"` must be +an integer, but these restrictions might loosen in the future. + +Another optional field is + +```C++ +"time_constraint": { "since": "game_start", "is": "<=", "target": "1 minute" } +``` + +This allows putting a time limit (either a lower or upper bound) on when the +achievement can be claimed. The `"since"` field can be either `"game_start"` +or `"cataclysm"`. The `"target"` describes an amount of time since that +reference point. + +Note that achievements can only be captured when a statistic listed in their +requirements changes. So, if you want an achievement such as "survived a +certain amount of time" which effectively only has a time constraint then you +must still place some requirement alongside it; pick some statistic which is +likely to change often, and a vacuous or weak constraint on it. + ### Skills ```C++ From 98303c2feca1b0f82a0c232d3ffa3d1b19552041 Mon Sep 17 00:00:00 2001 From: John Bytheway Date: Sat, 18 Apr 2020 09:57:55 -0400 Subject: [PATCH 6/7] Fix off-by-one error in time printing Was printing e.g. "day 0" when it was "day 1". --- src/calendar.cpp | 2 +- tests/stats_tracker_test.cpp | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/calendar.cpp b/src/calendar.cpp index d3c7ec65877fa..464a0fd53a3e3 100644 --- a/src/calendar.cpp +++ b/src/calendar.cpp @@ -553,7 +553,7 @@ std::string to_string( const time_point &p ) //~ 1 is the year, 2 is the day (of the *year*), 3 is the time of the day in its usual format return string_format( _( "Year %1$d, day %2$d %3$s" ), year, day, time ); } else { - const int day = day_of_season( p ); + const int day = day_of_season( p ) + 1; //~ 1 is the year, 2 is the season name, 3 is the day (of the season), 4 is the time of the day in its usual format return string_format( _( "Year %1$d, %2$s, day %3$d %4$s" ), year, calendar::name_season( season_of_year( p ) ), day, time ); diff --git a/tests/stats_tracker_test.cpp b/tests/stats_tracker_test.cpp index 6f549a2708134..267215098ae12 100644 --- a/tests/stats_tracker_test.cpp +++ b/tests/stats_tracker_test.cpp @@ -273,16 +273,16 @@ TEST_CASE( "achievments_tracker", "[stats]" ) 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 0 0000.30\n" + " Completed Year 1, Spring, day 1 0000.30\n" " 1/1 Number of zombies killed\n" ); CHECK( a.ui_text_for( achievements_completed.at( a_kill_in_first_minute ) ) == "Rude awakening\n" - " Completed Year 1, Spring, day 0 0000.30\n" + " Completed Year 1, Spring, day 1 0000.30\n" " 1/1 Number of monsters 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 0 0010.00\n" + " Completed Year 1, Spring, day 1 0010.00\n" " 1/1 Number of zombies killed\n" ); CHECK( !achievements_completed.count( a_kill_in_first_minute ) ); CHECK( a.ui_text_for( &*a_kill_in_first_minute ) == From 9dfdf4fc62bc0b872501fc7335d2110e4578e955 Mon Sep 17 00:00:00 2001 From: John Bytheway Date: Sat, 18 Apr 2020 21:14:05 -0400 Subject: [PATCH 7/7] Override time format option To ensure consistent output from achievement tests. --- tests/stats_tracker_test.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/stats_tracker_test.cpp b/tests/stats_tracker_test.cpp index 267215098ae12..f6ceb931439cd 100644 --- a/tests/stats_tracker_test.cpp +++ b/tests/stats_tracker_test.cpp @@ -13,6 +13,7 @@ #include "string_id.h" #include "stringmaker.h" #include "type_id.h" +#include "options_helpers.h" TEST_CASE( "stats_tracker_count_events", "[stats]" ) { @@ -228,6 +229,8 @@ TEST_CASE( "stats_tracker_watchers", "[stats]" ) TEST_CASE( "achievments_tracker", "[stats]" ) { + override_option opt( "24_HOUR", "military" ); + std::map, const achievement *> achievements_completed; event_bus b; stats_tracker s;