Unlock corresponding achievements when setting stat values

Accept a list of associated achievements when setting a stat value. If
any of the achievements were linked to the given stat, unlock the
achievement if the stat value meets the minimum value set for the
achievement.

If a stat is a sum of other stats, prevent the stat from being set, and
if the value of that stat is requested, require an associated list of
other stats to search through to calculate the sum.

Require a stat object when unlocking an achievement, rather than just
the stat's string ID.

Automatically convert a Stats object to a const reference to its
internal vector of Stats for easy access.

Bug fix: do not require a name field when parsing stats and achievements
in a config file.
This commit is contained in:
Cocktail Frank 2024-10-10 17:08:05 -04:00
parent a0cc137033
commit 01d6d03df4
3 changed files with 168 additions and 50 deletions

View File

@ -48,7 +48,7 @@ Stats::Stats(const nlohmann::json& config) : Properties("stats")
{
for (const nlohmann::json& stat : config.at(key))
{
if (stat.contains("id") && stat.contains("name"))
if (stat.contains("id"))
{
Stat::Type type = Stat::INT;
if (stat.contains("type") && stat.at("type") == "FLOAT")
@ -74,19 +74,19 @@ Stats::Stats(const nlohmann::json& config) : Properties("stats")
if (stat.contains("max"))
{
properties.emplace_back(Stat{
stat.at("id").get<std::string>(), stat.at("name").get<std::string>(), type,
stat.at("id").get<std::string>(), stat.value("name", ""), type,
stat.value("aggregated", true), stat.value("increment only", true), sum, stat.at("max")});
}
else
{
properties.emplace_back(Stat{
stat.at("id").get<std::string>(), stat.at("name").get<std::string>(), type,
stat.at("id").get<std::string>(), stat.value("name", ""), type,
stat.value("aggregated", true), stat.value("increment only", true), sum});
}
}
else
{
sb::Log::Multi(sb::Log::WARN) << "Stat must define the \"id\" and \"name\" fields: " << stat <<
sb::Log::Multi(sb::Log::WARN) << "Stat must define the \"id\" field: " << stat <<
sb::Log::end;
}
}
@ -98,6 +98,11 @@ Stats::Stats(const nlohmann::json& config) : Properties("stats")
}
}
Stats::operator const std::vector<Stat>&() const
{
return properties;
}
Achievement::Achievement(const std::string& id, const std::string& name, const std::string& description,
const sb::Sprite& locked_sprite, const sb::Sprite& unlocked_sprite,
std::optional<Unlocker> unlocker) :
@ -119,11 +124,11 @@ const std::string& Achievement::description() const
return _description;
}
bool Achievement::unlocks(const std::string& stat_id, float value) const
bool Achievement::unlocks(const Stat& stat, float value) const
{
if (unlocker.has_value())
{
return unlocker.value().stat_id == stat_id && value >= unlocker.value().goal;
return unlocker.value().stat_id == stat.id() && value >= unlocker.value().goal;
}
else
{
@ -137,7 +142,7 @@ Achievements::Achievements(const nlohmann::json& config) : Properties("achieveme
{
for (const nlohmann::json& achievement : config.at(key))
{
if (achievement.contains("id") && achievement.contains("name"))
if (achievement.contains("id"))
{
sb::Sprite unlocked_sprite;
sb::Sprite locked_sprite;
@ -155,12 +160,12 @@ Achievements::Achievements(const nlohmann::json& config) : Properties("achieveme
unlocker = Unlocker {achievement.at("stat").at("id"), achievement.at("stat").at("unlock")};
}
properties.emplace_back(Achievement{
achievement.at("id").get<std::string>(), achievement.at("name").get<std::string>(),
achievement.at("id").get<std::string>(), achievement.value("name", ""),
achievement.value("description", ""), locked_sprite, unlocked_sprite, unlocker});
}
else
{
sb::Log::Multi(sb::Log::WARN) << "Achievement must define the \"id\" and \"name\" fields: " <<
sb::Log::Multi(sb::Log::WARN) << "Achievement must define the \"id\" field: " <<
achievement << sb::Log::end;
}
}
@ -302,7 +307,7 @@ bool Progress::achievement_unlocked(const Achievement& achievement) const
bool Progress::unlocks(const Stat& stat, const Achievement& achievement) const
{
return achievement.unlocks(stat.id(), this->stat(stat));
return achievement.unlocks(stat.id(), stat_value(stat));
}
bool Progress::stat_exists(const Stat& stat)
@ -318,7 +323,7 @@ void Progress::add_stat(const Stat& stat)
}
}
void Progress::increment_stat(const Stat& stat, float amount)
void Progress::increment_stat(const Stat& stat, float amount, const std::vector<Achievement>& associates)
{
/* Add stat to the progress storage if it doesn't exist already */
if (!stat_exists(stat))
@ -327,46 +332,103 @@ void Progress::increment_stat(const Stat& stat, float amount)
}
/* All necessary value checks will happen in set_stat */
set_stat(stat, contents.at(stats_key).at(stat.id()).get<float>() + amount);
set_stat(stat, contents.at(stats_key).at(stat.id()).get<float>() + amount, associates);
}
void Progress::set_stat(const Stat& stat, float value)
void Progress::set_stat(const Stat& stat, float value, const std::vector<Achievement>& associates)
{
/* Add stat to the progress storage if it doesn't exist already */
if (!stat_exists(stat))
if (!stat.sum().empty())
{
add_stat(stat);
sb::Log::Multi(sb::Log::WARN) << "Prevented attempt to assign value to " << stat.id() << " which is defined " <<
"to be a sum of other stats" << sb::Log::end;
}
nlohmann::json& current_stat = contents.at(stats_key).at(stat.id());
if (current_stat.get<float>() < value || !stat.increment_only())
else
{
/* Clamp the stat value to the max if necessary */
if (stat.max().has_value() && value > stat.max().value())
/* Add stat to the progress storage if it doesn't exist already */
if (!stat_exists(stat))
{
value = stat.max().value();
add_stat(stat);
}
/* Round value if storing as INT */
if (stat.type() == Stat::Type::INT)
nlohmann::json& current_stat = contents.at(stats_key).at(stat.id());
if (current_stat.get<float>() < value || !stat.increment_only())
{
value = std::round(value);
}
/* Clamp the stat value to the max if necessary */
if (stat.max().has_value() && value > stat.max().value())
{
value = stat.max().value();
}
current_stat = value;
/* Round value if storing as INT */
if (stat.type() == Stat::Type::INT)
{
value = std::round(value);
}
current_stat = value;
#if defined(STEAM_ENABLED)
if (sb::cloud::steam::initialized())
{
set_steam_stat(stat);
}
if (sb::cloud::steam::initialized())
{
set_steam_stat(stat);
}
#endif
}
/* Unlock associated achievements */
for (const Achievement& achievement : associates)
{
if (achievement.unlocks(stat, current_stat))
{
unlock_achievement(achievement);
}
}
}
}
float Progress::stat(const Stat& stat) const
float Progress::stat_value(const Stat& stat, const std::vector<Stat>& associates) const
{
return read(stats_key, stat.id());
if (!stat.sum().empty())
{
/* If there are no associates, it's not possible to calculate the sum */
if (associates.empty())
{
std::ostringstream message;
message << stat.id() << " is defined as a sum of other stats, but there were no associated stats passed " <<
"to calculate the sum from.";
throw std::out_of_range(message.str());
}
float sum = 0.0f;
/* Find every stat necessary to calculate the sum */
for (const Stat& operand : stat.sum())
{
bool found = false;
for (const Stat& associate : associates)
{
if (operand.id() == associate.id())
{
found = true;
sum += stat_value(associate);
}
}
if (!found)
{
std::ostringstream message;
message << stat.id() << " is defined as a sum of other stats, but one or more of the members of " <<
" the sum were not found in the given associated stats: " << operand.id();
throw std::out_of_range(message.str());
}
}
return sum;
}
else
{
return read(stats_key, stat.id());
}
}
#if defined(STEAM_ENABLED)

View File

@ -200,6 +200,11 @@ namespace sb::progress
* @overload Stats::Stats(const nlohmann::json&)
*/
Stats(const sb::Configuration& config) : Stats(config()) {};
/*!
* @return A read-only reference to the stats as a vector.
*/
operator const std::vector<Stat>&() const;
};
class Progress;
@ -262,12 +267,12 @@ namespace sb::progress
const sb::Sprite& unlocked_sprite() const;
/*!
* @param stat_id Check if this stat ID is the correct unlocker stat
* @param value Check if the value is high enough to reach the unlocker value
* @param stat Check if this stat object is the correct unlocker stat
* @param value Check if the value is high enough to reach the unlocker value
*
* @return True if the given stat is needed to unlock this achievement and the value is the necessary amount
*/
bool unlocks(const std::string& stat_id, float value) const;
bool unlocks(const Stat& stat, float value) const;
};
@ -438,11 +443,16 @@ namespace sb::progress
* If the program has been compiled with Steam API support, this will attempt to set the stat via the Steam
* API's SetStat function. To store the stat on Steam's servers, the sb::cloud::steam::store_stats() function
* needs to be run afterward.
*
* If the optional `associates` parameter is set to a list of Achievement objects, the list will be checked
* for any achievements which are unlocked by the stat being incremented. If the stat value is greater than or
* equal to the unlock value defined in the achievement, the achievement will be automatically unlocked.
*
* @param stat A statistic to update
* @param amount Amount to increment (or decrement if negative)
* @param stat A statistic to update
* @param amount Amount to increment (or decrement if negative)
* @param associates List of achievement definitions to check for automatic unlocking
*/
void increment_stat(const Stat& stat, float amount = 1.0f);
void increment_stat(const Stat& stat, float amount = 1.0f, const std::vector<Achievement>& associates = {});
/*!
* Set the value of the given stat to the given value and store the value in this progress object.
@ -452,20 +462,37 @@ namespace sb::progress
* The value given can be any negative or positive number, but if the stat's "increment only" or "max"
* properties are set and the value is too low or high, the value will not be set.
*
* If the program has been compiled with Steam API support, this will attempt to sync the stat with the currently
* active Steam account.
* If the program has been compiled with Steam API support, this will attempt to sync the stat with the
* currently active Steam account.
*
* Note that if the given stat object defines the stat to be the sum of other stats, this function will not set
* the value of the stat and will log a warning.
*
* @param stat A statistic to update
* @param value The new value for the stat
* If the optional `associates` parameter is set to a list of Achievement objects, the list will be checked
* for any achievements which are unlocked by the stat being incremented. If the stat value is greater than or
* equal to the unlock value defined in the achievement, the achievement will be automatically unlocked.
*
* @param stat A statistic to update
* @param value The new value for the stat
* @param associates List of achievement definitions to check for automatic unlocking
*/
void set_stat(const Stat& stat, float value);
void set_stat(const Stat& stat, float value, const std::vector<Achievement>& associates = {});
/*!
* @param stat Stat to get a value for
* Get the value of a given stat.
*
* @return The value stored for the stat
* If the stat is defined as a sum of other stats, the `associates` parameter must be provided. The `associates`
* are other stats to use to determine the sum. All associate stats that comprise the sum of the given stat must
* be present in `associates`.
*
* @param stat Stat to get a value for
*
* @return The value stored for the stat
*
* @throw std::out_of_range If the given stat is a sum and the necessary stats to form the sum are not in
* associates
*/
float stat(const Stat& stat) const;
float stat_value(const Stat& stat, const std::vector<Stat>& associates = {}) const;
/*!
* Store the given JSON in the config section of this progress object. This can be used to store config

View File

@ -354,6 +354,18 @@ TEST_CASE("Progress with stats and achievements")
"type": "FLOAT",
"aggregated": false,
"increment only": false
},
{
"id": "STAT_SLICER_DEATHS"
},
{
"id": "STAT_FISH_DEATHS"
},
{
"id": "STAT_DRONE_DEATHS"
},
{
"id": "STAT_FIRE_DEATHS"
}
]
})"_json;
@ -400,7 +412,7 @@ TEST_CASE("Progress with stats and achievements")
CHECK_THROWS_AS(progress.read("dankey", "kang"), std::runtime_error);
CHECK(progress.read("stats", "STAT_DISTANCE_TRAVELED") == 12'345);
CHECK(progress.stat_exists(distance));
CHECK(progress.stat(distance) == 12'345);
CHECK(progress.stat_value(distance) == 12'345);
/* Add arbitrary progress fields */
progress.set(false, "waluigi", "defeated");
@ -419,12 +431,29 @@ TEST_CASE("Progress with stats and achievements")
CHECK(achievements[0].id() == "ACH_LAYER_CAKE");
CHECK(achievements[0].name() == "Layer Cake");
CHECK(achievements["ACH_LAYER_CAKE"].description() == "Walk 10 thousand meters");
CHECK_FALSE(achievements["ACH_LAYER_CAKE"].unlocks(distance.id(), 0'001));
CHECK(achievements["ACH_LAYER_CAKE"].unlocks(distance.id(), progress.stat(distance)));
CHECK_FALSE(achievements["ACH_LAYER_CAKE"].unlocks(distance, 0'001));
CHECK(achievements["ACH_LAYER_CAKE"].unlocks(distance, progress.stat_value(distance)));
CHECK(achievements[1].id() == "ACH_CAKEWALK");
CHECK(achievements["ACH_CAKEWALK"].name() == "Cakewalk");
CHECK(achievements["ACH_CAKEWALK"].description() == "Beat the game");
/* Check sum handling */
CHECK_THROWS_AS(progress.stat_value(stats["STAT_TOTAL_DEATHS"]), std::out_of_range);
CHECK_THROWS_AS(
progress.stat_value(stats["STAT_TOTAL_DEATHS"], {
sb::progress::Stat{"Hello, World!"},
sb::progress::Stat{"Dankey Kang XP"}
}), std::out_of_range);
progress.increment_stat(stats["STAT_SLICER_DEATHS"], 1);
progress.increment_stat(stats["STAT_FISH_DEATHS"], 10);
progress.increment_stat(stats["STAT_DRONE_DEATHS"], 100);
progress.increment_stat(stats["STAT_FIRE_DEATHS"], 1000);
CHECK(progress.stat_value(stats["STAT_TOTAL_DEATHS"], stats) == 1111);
progress.set_stat(stats["STAT_TOTAL_DEATHS"], 2222);
CHECK(progress.stat_value(stats["STAT_TOTAL_DEATHS"], stats) == 1111);
progress.increment_stat(stats["STAT_TOTAL_DEATHS"]);
CHECK(progress.stat_value(stats["STAT_TOTAL_DEATHS"], stats) == 1111);
/* Store user config overrides */
nlohmann::json preferences {
{"display", {