Add a function to the `sb:☁️:HTTP` class that sends session data collected by a `sb::progress::Progress` object to a configured URL. Add a PHP script for receiving the session data, intended to be installed separately on a remote server. Add a randomly generated ID when writing new save files. Do not overwrite existing IDs, so the ID will persist for the life of the file. Store the ID in the play session data. Add a string identifying the program's build, either "windows", "linux", "macos", "wasm", or "android". Add function for accessing requests in the HTTP queue. Add the `sb::math` namespace for functions in math.hpp. Add open source SHA256 hash library to the project. Use the library to hash the player's Steam username before saving it to the session data. Add documentation for `sb:☁️:http::post_session` to README. Add a function for getting a string representing the current date up to the minute to `sb::time`. This function is used to store the start time of the play session. Add test of session transfer to HTTP test case.
654 lines
21 KiB
C++
654 lines
21 KiB
C++
/* +=======================================================+
|
|
____/ \____ /: Open source game framework licensed to freely use, :
|
|
\ / / : copy, and modify - created for dank.game :
|
|
+==\ ^__^ /==+ : :
|
|
: ~/ \~ : : Download at https://open.shampoo.ooo/shampoo/spacebox :
|
|
: ~~~~~~~~~~~~ : +=======================================================+
|
|
: SPACE ~~~~~ : /
|
|
: ~~~~~~~ BOX :/
|
|
+=============*/
|
|
|
|
#include "progress.hpp"
|
|
|
|
using namespace sb::progress;
|
|
|
|
Stat::Stat(const std::string& id, const std::string& name, Type type, bool aggregated, bool increment_only,
|
|
const std::vector<std::string>& sum, std::optional<float> max)
|
|
: _id(id), _name(name), _type(type), _aggregated(aggregated), _increment_only(increment_only), _sum(sum),
|
|
_max(max) {};
|
|
|
|
const std::string& Stat::name() const
|
|
{
|
|
return _name;
|
|
}
|
|
|
|
const std::string& Stat::id() const
|
|
{
|
|
return _id;
|
|
}
|
|
|
|
Stat::Type Stat::type() const
|
|
{
|
|
return _type;
|
|
}
|
|
|
|
bool Stat::aggregated() const
|
|
{
|
|
return _aggregated;
|
|
}
|
|
|
|
bool Stat::increment_only() const
|
|
{
|
|
return _increment_only;
|
|
}
|
|
|
|
const std::vector<std::string>& Stat::sum() const
|
|
{
|
|
return _sum;
|
|
}
|
|
|
|
std::optional<float> Stat::max() const
|
|
{
|
|
return _max;
|
|
}
|
|
|
|
Stats::Stats(const nlohmann::json& config) : Properties("stats")
|
|
{
|
|
if (config.contains(key))
|
|
{
|
|
for (const nlohmann::json& stat : config.at(key))
|
|
{
|
|
if (stat.contains("id"))
|
|
{
|
|
Stat::Type type = Stat::INT;
|
|
if (stat.contains("type") && stat.at("type") == "FLOAT")
|
|
{
|
|
type = Stat::FLOAT;
|
|
}
|
|
std::vector<std::string> sum;
|
|
if (stat.contains("sum"))
|
|
{
|
|
if (stat.at("sum").is_array())
|
|
{
|
|
for (const std::string id : stat.at("sum"))
|
|
{
|
|
sum.push_back(id);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Stat's \"sum\" field must contain an array: " <<
|
|
stat.at("sum") << sb::Log::end;
|
|
}
|
|
}
|
|
if (stat.contains("max"))
|
|
{
|
|
properties.emplace_back(Stat{
|
|
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.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\" field: " << stat <<
|
|
sb::Log::end;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Line(sb::Log::WARN) <<
|
|
"Tried to read stats from config, but config does not include a \"stats\" section.";
|
|
}
|
|
}
|
|
|
|
Achievement::Achievement(const std::string& id, const nlohmann::json& json, const std::string& name,
|
|
const std::string& description, const sb::Sprite& locked_sprite,
|
|
const sb::Sprite& unlocked_sprite, std::optional<Unlocker> unlocker) :
|
|
_id(id), _json(json), _name(name), _description(description), _locked_sprite(locked_sprite),
|
|
_unlocked_sprite(unlocked_sprite), unlocker(unlocker) {}
|
|
|
|
const std::string& Achievement::name() const
|
|
{
|
|
return _name;
|
|
}
|
|
|
|
const std::string& Achievement::id() const
|
|
{
|
|
return _id;
|
|
}
|
|
|
|
const std::string& Achievement::description() const
|
|
{
|
|
return _description;
|
|
}
|
|
|
|
bool Achievement::unlocks(const Stat& stat, float value) const
|
|
{
|
|
if (unlocker.has_value())
|
|
{
|
|
return unlocker.value().stat_id == stat.id() && value >= unlocker.value().goal;
|
|
}
|
|
else
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const nlohmann::json& Achievement::json() const
|
|
{
|
|
return _json;
|
|
}
|
|
|
|
Achievements::Achievements(const nlohmann::json& config) : Properties("achievements")
|
|
{
|
|
if (config.contains(key))
|
|
{
|
|
for (const nlohmann::json& achievement : config.at(key))
|
|
{
|
|
if (achievement.contains("id"))
|
|
{
|
|
sb::Sprite unlocked_sprite;
|
|
sb::Sprite locked_sprite;
|
|
if (achievement.contains("image") && achievement.at("image").contains("unlocked"))
|
|
{
|
|
unlocked_sprite.texture(achievement.at("image").at("unlocked"), GL_LINEAR);
|
|
}
|
|
if (achievement.contains("image") && achievement.at("image").contains("locked"))
|
|
{
|
|
locked_sprite.texture(achievement.at("image").at("locked"), GL_LINEAR);
|
|
}
|
|
std::optional<Unlocker> unlocker;
|
|
if (achievement.contains("stat"))
|
|
{
|
|
unlocker = Unlocker {achievement.at("stat").at("id"), achievement.at("stat").at("unlock")};
|
|
}
|
|
properties.emplace_back(Achievement{
|
|
achievement.at("id").get<std::string>(), achievement, achievement.value("name", ""),
|
|
achievement.value("description", ""), locked_sprite, unlocked_sprite, unlocker});
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Achievement must define the \"id\" field: " <<
|
|
achievement << sb::Log::end;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Line(sb::Log::WARN) <<
|
|
"Tried to read stats from config, but config does not include a \"stats\" section.";
|
|
}
|
|
}
|
|
|
|
Progress::Progress(std::size_t id_length)
|
|
{
|
|
_session["id"] = sb::math::random::id(id_length);
|
|
_session["start time"] = sb::time::minute_stamp(std::chrono::system_clock::now());
|
|
}
|
|
|
|
const std::vector<std::string> Progress::reserved() const
|
|
{
|
|
return std::vector<std::string> {config_key, stats_key, achievements_key};
|
|
}
|
|
|
|
void Progress::load(const fs::path& path, const nlohmann::json& defaults)
|
|
{
|
|
load(json_from_file(path), defaults);
|
|
}
|
|
|
|
void Progress::load(const nlohmann::json& json, const nlohmann::json& defaults)
|
|
{
|
|
if (defaults.contains(config_key) || defaults.contains(stats_key) || defaults.contains(achievements_key))
|
|
{
|
|
std::ostringstream message;
|
|
message << "Default progress cannot be defined for '" << config_key << "', '" << "'"<< stats_key << "', or '" <<
|
|
achievements_key << "' because they are reserved for special data. ";
|
|
throw ReservedError(message.str());
|
|
}
|
|
contents = {
|
|
{achievements_key, nlohmann::json::array()},
|
|
{stats_key, nlohmann::json::object()},
|
|
{config_key, nlohmann::json::object()}
|
|
};
|
|
contents.merge_patch(defaults);
|
|
contents.merge_patch(json);
|
|
|
|
/* Data has just been loaded, so progress must not be unsaved */
|
|
unsaved = false;
|
|
}
|
|
|
|
void Progress::save(fs::path path, bool force)
|
|
{
|
|
if (unsaved || force)
|
|
{
|
|
/* If there are any parent directories, this will create them if they don't already exist. */
|
|
if (path.has_parent_path())
|
|
{
|
|
bool created;
|
|
try
|
|
{
|
|
created = fs::create_directories(path.parent_path());
|
|
}
|
|
catch (fs::filesystem_error error)
|
|
{
|
|
sb::Log::Multi(sb::Log::ERR) << "Could not create directory for progress file. " <<
|
|
"Progress will not be saved. " << error.what() << sb::Log::end;
|
|
return;
|
|
}
|
|
if (created)
|
|
{
|
|
sb::Log::Multi() << "Created directory for progress file at " << path.parent_path();
|
|
try
|
|
{
|
|
/* Set permissions to rwx/r-x/r-x (only owner has write permissions) */
|
|
fs::permissions(
|
|
path.parent_path(),
|
|
fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec |
|
|
fs::perms::others_read | fs::perms::others_exec);
|
|
}
|
|
catch (fs::filesystem_error error)
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Could not set permissions for progress directory at " <<
|
|
path.parent_path() << ". " << error.what() << sb::Log::end;
|
|
} } }
|
|
|
|
/* Create an ID for the file if one wasn't assigned already. Since the value will be loaded with the rest of the
|
|
* file contents, the ID will remain the same for the life of the file. */
|
|
if (!contents.contains("_id"))
|
|
{
|
|
contents["_id"] = sb::math::random::id(6);
|
|
}
|
|
|
|
/* Save player's progress file */
|
|
std::ofstream progress_file;
|
|
try
|
|
{
|
|
progress_file = std::ofstream {path};
|
|
}
|
|
catch (std::exception exception)
|
|
{
|
|
sb::Log::Multi(sb::Log::ERR) << "Error opening progress file at " << path <<
|
|
". Progress will not be saved" << sb::Log::end;
|
|
return;
|
|
}
|
|
|
|
if (progress_file << std::setw(4) << contents << std::endl)
|
|
{
|
|
unsaved = false;
|
|
sb::Log::Multi() << "Successfully saved progress to " << path << sb::Log::end;
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::ERR) << "Could not save progress to " << path << sb::Log::end;
|
|
}
|
|
progress_file.close();
|
|
|
|
/* Set file permissions to rw-/r--/r-- */
|
|
try
|
|
{
|
|
fs::permissions(
|
|
path, fs::perms::owner_read | fs::perms::owner_write | fs::perms::group_read | fs::perms::group_write);
|
|
}
|
|
catch (fs::filesystem_error error)
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Could not set permissions for progress file at " << path << ". " <<
|
|
error.what() << sb::Log::end;
|
|
}
|
|
|
|
#if defined(EMSCRIPTEN)
|
|
EM_ASM(
|
|
FS.syncfs(false, function(error) {
|
|
if (error !== null)
|
|
{
|
|
console.log("Error syncing storage using Filesystem API", error);
|
|
}
|
|
});
|
|
);
|
|
#endif
|
|
}
|
|
}
|
|
|
|
void Progress::unlock_achievement(const Achievement& achievement)
|
|
{
|
|
#if defined(STEAM_ENABLED)
|
|
if (sb::cloud::steam::initialized())
|
|
{
|
|
unlock_steam_achievement(achievement.id());
|
|
}
|
|
#endif
|
|
|
|
if (!sb::contains(contents.at(achievements_key), achievement.id()))
|
|
{
|
|
contents.at(achievements_key).push_back(achievement.id());
|
|
unsaved = true;
|
|
|
|
/* Notify the log. */
|
|
sb::Log::Multi() << "Unlocked achievement " << achievement.id() << sb::Log::end;
|
|
|
|
/* Store a list of unlocked achievement IDs the application can use for notifications. */
|
|
unlocked.push_back(achievement.id());
|
|
|
|
#if defined(STEAM_ENABLED)
|
|
if (sb::cloud::steam::initialized())
|
|
{
|
|
/* Sync to Steam's server immediately so the notification will pop up. Normally, this is not called from the
|
|
* library to help prevent it from being called too often, but in this case, the frequency of unlocking an
|
|
* achievement that was previously locked is infrequent enough to safely call. */
|
|
sb::cloud::steam::store_stats();
|
|
}
|
|
#endif
|
|
|
|
/* Store in the session JSON. This is separate from the unlocked vector because the session JSON is guaranteed
|
|
* to not be cleared for the life of the progress object. */
|
|
_session.at(achievements_key).push_back(achievement.id());
|
|
}
|
|
}
|
|
|
|
bool Progress::achievement_unlocked(const Achievement& achievement) const
|
|
{
|
|
return sb::contains(contents.at(achievements_key), achievement.id());
|
|
}
|
|
|
|
int Progress::achievement_count() const
|
|
{
|
|
return read(achievements_key).size();
|
|
}
|
|
|
|
const std::vector<std::string>& Progress::live_achievements(bool clear)
|
|
{
|
|
if (clear)
|
|
{
|
|
unlocked.clear();
|
|
}
|
|
return unlocked;
|
|
}
|
|
|
|
bool Progress::unlocks(const Stat& stat, const Achievement& achievement) const
|
|
{
|
|
return achievement.unlocks(stat.id(), stat_value(stat));
|
|
}
|
|
|
|
bool Progress::stat_exists(const Stat& stat) const
|
|
{
|
|
return contents.at(stats_key).contains(stat.id());
|
|
}
|
|
|
|
void Progress::add_stat(const Stat& stat)
|
|
{
|
|
if (!stat_exists(stat))
|
|
{
|
|
contents.at(stats_key)[stat.id()] = 0;
|
|
unsaved = true;
|
|
}
|
|
}
|
|
|
|
void Progress::increment_stat(const Stat& stat, float amount, const std::vector<Achievement>& associated_achievements,
|
|
const std::vector<Stat>& associated_stats)
|
|
{
|
|
/* Add stat to the progress storage if it doesn't exist already */
|
|
if (!stat_exists(stat))
|
|
{
|
|
add_stat(stat);
|
|
}
|
|
|
|
/* Track the increment value for this session separately so that existing data from previous sessions isn't
|
|
* included. */
|
|
_session[stat.id()] = _session.value(stat.id(), 0.0f) + amount;
|
|
|
|
/* All necessary value checks will happen in set_stat. Set skip_session to true because session value was set
|
|
* already above. */
|
|
set_stat(stat,
|
|
contents.at(stats_key).at(stat.id()).get<float>() + amount,
|
|
associated_achievements,
|
|
associated_stats,
|
|
true
|
|
);
|
|
}
|
|
|
|
void Progress::set_stat(
|
|
const Stat& stat,
|
|
float value,
|
|
const std::vector<Achievement>& associated_achievements,
|
|
const std::vector<Stat>& associated_stats,
|
|
bool skip_session)
|
|
{
|
|
if (!stat.sum().empty())
|
|
{
|
|
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;
|
|
}
|
|
else
|
|
{
|
|
/* Add stat to the progress storage if it doesn't exist already */
|
|
if (!stat_exists(stat))
|
|
{
|
|
add_stat(stat);
|
|
}
|
|
|
|
nlohmann::json& current_value = contents.at(stats_key).at(stat.id());
|
|
if (current_value.get<float>() != value && (current_value.get<float>() < value || !stat.increment_only()))
|
|
{
|
|
/* Clamp the stat value to the max if necessary */
|
|
if (stat.max().has_value() && value > stat.max().value())
|
|
{
|
|
value = stat.max().value();
|
|
}
|
|
|
|
/* Round value if storing as INT */
|
|
if (stat.type() == Stat::Type::INT)
|
|
{
|
|
value = std::round(value);
|
|
}
|
|
|
|
/* Set value and register change */
|
|
current_value = value;
|
|
unsaved = true;
|
|
unlock_associated_achievements(stat, current_value, associated_achievements);
|
|
|
|
/* Set stat on Steam */
|
|
#if defined(STEAM_ENABLED)
|
|
if (sb::cloud::steam::initialized())
|
|
{
|
|
set_steam_stat(stat);
|
|
}
|
|
#endif
|
|
|
|
/* Check for stats which include the updated stat as part of its sum.*/
|
|
for (const Stat& associated_stat : associated_stats)
|
|
{
|
|
for (const std::string& component_id : associated_stat.sum())
|
|
{
|
|
if (component_id == stat.id())
|
|
{
|
|
/* Check for achievements that are unlocked by the new sum. */
|
|
unlock_associated_achievements(
|
|
associated_stat, stat_value(associated_stat, associated_stats), associated_achievements);
|
|
|
|
#if defined(STEAM_ENABLED)
|
|
/* Although the value of these stats are determined dynamically, the value on Steam needs to be
|
|
* set manually. */
|
|
if (sb::cloud::steam::initialized())
|
|
{
|
|
set_steam_stat(associated_stat, associated_stats);
|
|
}
|
|
#endif
|
|
|
|
break;
|
|
} } }
|
|
|
|
/* Track stat value setting for this session */
|
|
if (!skip_session)
|
|
{
|
|
_session[stat.id()] = value;
|
|
} } }
|
|
}
|
|
|
|
void Progress::unlock_associated_achievements(
|
|
const Stat& stat, float value, const std::vector<Achievement>& achievements)
|
|
{
|
|
/* Unlock associated achievements */
|
|
for (const Achievement& achievement : achievements)
|
|
{
|
|
if (achievement.unlocks(stat, value))
|
|
{
|
|
unlock_achievement(achievement);
|
|
}
|
|
}
|
|
}
|
|
|
|
float Progress::stat_value(const Stat& stat, const std::vector<Stat>& associates) const
|
|
{
|
|
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;
|
|
|
|
/* If the stat doesn't have a value in the progress yet, count it as zero. */
|
|
if (stat_exists(associate))
|
|
{
|
|
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());
|
|
}
|
|
}
|
|
|
|
float Progress::stat_default(const Stat& stat, float default_value, const std::vector<Stat>& associates) const
|
|
{
|
|
if (!stat_exists(stat) && stat.sum().empty())
|
|
{
|
|
return default_value;
|
|
}
|
|
else
|
|
{
|
|
return stat_value(stat, associates);
|
|
}
|
|
}
|
|
|
|
#if defined(STEAM_ENABLED)
|
|
|
|
void Progress::sync_to_steam(const Achievements& achievements, const Stats& stats) const
|
|
{
|
|
if (sb::cloud::steam::initialized())
|
|
{
|
|
for (const std::string id : contents.at(achievements_key))
|
|
{
|
|
unlock_steam_achievement(achievements[id]);
|
|
}
|
|
|
|
for (auto [id, value] : contents.at(stats_key).items())
|
|
{
|
|
set_steam_stat(stats[id], stats);
|
|
|
|
/* Check for stats which include the updated stat as part of its sum.*/
|
|
for (const Stat& associated_stat : stats)
|
|
{
|
|
for (const std::string& component_id : associated_stat.sum())
|
|
{
|
|
if (component_id == id)
|
|
{
|
|
set_steam_stat(associated_stat, stats);
|
|
} } } } }
|
|
}
|
|
|
|
void Progress::set_steam_stat(const Stat& stat, const std::vector<Stat>& associates) const
|
|
{
|
|
if (sb::cloud::steam::initialized())
|
|
{
|
|
float value = stat_value(stat, associates);
|
|
if (stat.type() == Stat::Type::INT)
|
|
{
|
|
SteamAPI_ISteamUserStats_SetStatInt32(
|
|
SteamUserStats(), stat.id().c_str(), static_cast<int>(value));
|
|
}
|
|
else
|
|
{
|
|
SteamAPI_ISteamUserStats_SetStatFloat(SteamUserStats(), stat.id().c_str(), value);
|
|
}
|
|
}
|
|
}
|
|
|
|
void Progress::unlock_steam_achievement(const Achievement& achievement) const
|
|
{
|
|
if (sb::cloud::steam::initialized())
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Unlocking " << achievement.id() << " on Steam" << sb::Log::end;
|
|
if (!SteamAPI_ISteamUserStats_SetAchievement(SteamUserStats(), achievement.id().c_str()))
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Failed to unlock achievement " << achievement.id() << " on Steam" <<
|
|
sb::Log::end;
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
void Progress::config(const nlohmann::json& json)
|
|
{
|
|
contents[config_key].merge_patch(json);
|
|
unsaved = true;
|
|
}
|
|
|
|
void Progress::merge(sb::Configuration& config) const
|
|
{
|
|
if (contents.contains(config_key))
|
|
{
|
|
config.merge(contents.at(config_key));
|
|
}
|
|
}
|
|
|
|
bool Progress::operator==(const Progress& other) const
|
|
{
|
|
return contents == other.contents;
|
|
}
|
|
|
|
std::string Progress::id() const
|
|
{
|
|
return contents.value("_id", "");
|
|
}
|
|
|
|
const nlohmann::json& Progress::session()
|
|
{
|
|
_session["save data id"] = id();
|
|
return _session;
|
|
}
|