2023-08-06 21:33:15 +02:00
|
|
|
#pragma once
|
|
|
|
|
2023-11-30 14:40:07 +01:00
|
|
|
#include <hex.hpp>
|
|
|
|
|
|
|
|
#include <functional>
|
2023-11-30 11:23:12 +01:00
|
|
|
#include <list>
|
|
|
|
#include <memory>
|
|
|
|
#include <span>
|
2023-08-06 21:33:15 +02:00
|
|
|
#include <string>
|
2023-11-30 14:40:07 +01:00
|
|
|
#include <unordered_map>
|
2023-08-06 21:33:15 +02:00
|
|
|
#include <utility>
|
|
|
|
#include <vector>
|
|
|
|
|
|
|
|
#include <imgui.h>
|
|
|
|
#include <hex/ui/imgui_imhex_extensions.h>
|
2023-12-19 12:22:28 +01:00
|
|
|
#include <hex/api/localization_manager.hpp>
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
namespace hex {
|
|
|
|
|
|
|
|
class AchievementManager;
|
|
|
|
|
|
|
|
class Achievement {
|
|
|
|
public:
|
2023-12-19 12:22:28 +01:00
|
|
|
explicit Achievement(UnlocalizedString unlocalizedCategory, UnlocalizedString unlocalizedName) : m_unlocalizedCategory(std::move(unlocalizedCategory)), m_unlocalizedName(std::move(unlocalizedName)) { }
|
2023-08-06 21:33:15 +02:00
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns the unlocalized name of the achievement
|
|
|
|
* @return Unlocalized name of the achievement
|
|
|
|
*/
|
2023-12-19 12:22:28 +01:00
|
|
|
[[nodiscard]] const UnlocalizedString &getUnlocalizedName() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_unlocalizedName;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns the unlocalized category of the achievement
|
|
|
|
* @return Unlocalized category of the achievement
|
|
|
|
*/
|
2023-12-19 12:22:28 +01:00
|
|
|
[[nodiscard]] const UnlocalizedString &getUnlocalizedCategory() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_unlocalizedCategory;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns whether the achievement is unlocked
|
|
|
|
* @return Whether the achievement is unlocked
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
[[nodiscard]] bool isUnlocked() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_progress == m_maxProgress;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Sets the description of the achievement
|
|
|
|
* @param description Description of the achievement
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
Achievement& setDescription(std::string description) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_unlocalizedDescription = std::move(description);
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Adds a requirement to the achievement. The achievement will only be unlockable if all requirements are unlocked.
|
|
|
|
* @param requirement Unlocalized name of the requirement
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
Achievement& addRequirement(std::string requirement) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_requirements.emplace_back(std::move(requirement));
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Adds a visibility requirement to the achievement. The achievement will only be visible if all requirements are unlocked.
|
|
|
|
* @param requirement Unlocalized name of the requirement
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
Achievement& addVisibilityRequirement(std::string requirement) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_visibilityRequirements.emplace_back(std::move(requirement));
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Marks the achievement as blacked. Blacked achievements are visible but their name and description are hidden.
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
Achievement& setBlacked() {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_blacked = true;
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Marks the achievement as invisible. Invisible achievements are not visible at all.
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
Achievement& setInvisible() {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_invisible = true;
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns whether the achievement is blacked
|
|
|
|
* @return Whether the achievement is blacked
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
[[nodiscard]] bool isBlacked() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_blacked;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns whether the achievement is invisible
|
|
|
|
* @return Whether the achievement is invisible
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
[[nodiscard]] bool isInvisible() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_invisible;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns the list of requirements of the achievement
|
|
|
|
* @return List of requirements of the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
[[nodiscard]] const std::vector<std::string> &getRequirements() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_requirements;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns the list of visibility requirements of the achievement
|
|
|
|
* @return List of visibility requirements of the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
[[nodiscard]] const std::vector<std::string> &getVisibilityRequirements() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_visibilityRequirements;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns the unlocalized description of the achievement
|
|
|
|
* @return Unlocalized description of the achievement
|
|
|
|
*/
|
2023-12-19 12:22:28 +01:00
|
|
|
[[nodiscard]] const UnlocalizedString &getUnlocalizedDescription() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_unlocalizedDescription;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns the icon of the achievement
|
|
|
|
* @return Icon of the achievement
|
|
|
|
*/
|
2023-11-16 22:24:06 +01:00
|
|
|
[[nodiscard]] const ImGuiExt::Texture &getIcon() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
if (m_iconData.empty())
|
|
|
|
return m_icon;
|
2023-08-06 21:33:15 +02:00
|
|
|
|
2023-12-19 13:10:25 +01:00
|
|
|
if (m_icon.isValid())
|
2023-08-06 21:33:15 +02:00
|
|
|
return m_icon;
|
|
|
|
|
2023-12-19 13:10:25 +01:00
|
|
|
m_icon = ImGuiExt::Texture(m_iconData.data(), m_iconData.size(), ImGuiExt::Texture::Filter::Linear);
|
2023-08-06 21:33:15 +02:00
|
|
|
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_icon;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Sets the icon of the achievement
|
|
|
|
* @param data Icon data
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
Achievement& setIcon(std::span<const std::byte> data) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_iconData.reserve(data.size());
|
2023-08-06 21:33:15 +02:00
|
|
|
for (auto &byte : data)
|
2023-12-19 13:10:25 +01:00
|
|
|
m_iconData.emplace_back(static_cast<u8>(byte));
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Sets the icon of the achievement
|
|
|
|
* @param data Icon data
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
Achievement& setIcon(std::span<const u8> data) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_iconData.assign(data.begin(), data.end());
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Sets the icon of the achievement
|
|
|
|
* @param data Icon data
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
Achievement& setIcon(std::vector<u8> data) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_iconData = std::move(data);
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Sets the icon of the achievement
|
|
|
|
* @param data Icon data
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
|
|
|
Achievement& setIcon(const std::vector<std::byte> &data) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_iconData.reserve(data.size());
|
2023-08-06 21:33:15 +02:00
|
|
|
for (auto &byte : data)
|
2023-12-19 13:10:25 +01:00
|
|
|
m_iconData.emplace_back(static_cast<u8>(byte));
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Specifies the required progress to unlock the achievement. This is the number of times this achievement has to be triggered to unlock it. The default is 1.
|
|
|
|
* @param progress Required progress
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
Achievement& setRequiredProgress(u32 progress) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_maxProgress = progress;
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
return *this;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns the required progress to unlock the achievement
|
|
|
|
* @return Required progress to unlock the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
[[nodiscard]] u32 getRequiredProgress() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_maxProgress;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns the current progress of the achievement
|
|
|
|
* @return Current progress of the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
[[nodiscard]] u32 getProgress() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_progress;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Sets the callback to call when the achievement is clicked
|
|
|
|
* @param callback Callback to call when the achievement is clicked
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
void setClickCallback(const std::function<void(Achievement &)> &callback) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_clickCallback = callback;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns the callback to call when the achievement is clicked
|
|
|
|
* @return Callback to call when the achievement is clicked
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
[[nodiscard]] const std::function<void(Achievement &)> &getClickCallback() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_clickCallback;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns whether the achievement is temporary. Temporary achievements have been added by challenge projects for example and will be removed when the project is closed.
|
|
|
|
* @return Whether the achievement is temporary
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
[[nodiscard]] bool isTemporary() const {
|
2023-12-19 13:10:25 +01:00
|
|
|
return m_temporary;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Sets whether the achievement is unlocked
|
|
|
|
* @param unlocked Whether the achievement is unlocked
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
void setUnlocked(bool unlocked) {
|
|
|
|
if (unlocked) {
|
2023-12-19 13:10:25 +01:00
|
|
|
if (m_progress < m_maxProgress)
|
|
|
|
m_progress++;
|
2023-08-06 21:33:15 +02:00
|
|
|
} else {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_progress = 0;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
protected:
|
|
|
|
void setProgress(u32 progress) {
|
2023-12-19 13:10:25 +01:00
|
|
|
m_progress = progress;
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private:
|
2023-12-19 12:22:28 +01:00
|
|
|
UnlocalizedString m_unlocalizedCategory, m_unlocalizedName;
|
|
|
|
UnlocalizedString m_unlocalizedDescription;
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
bool m_blacked = false;
|
|
|
|
bool m_invisible = false;
|
|
|
|
std::vector<std::string> m_requirements, m_visibilityRequirements;
|
|
|
|
|
|
|
|
std::function<void(Achievement &)> m_clickCallback;
|
|
|
|
|
|
|
|
std::vector<u8> m_iconData;
|
2023-11-16 22:24:06 +01:00
|
|
|
mutable ImGuiExt::Texture m_icon;
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
u32 m_progress = 0;
|
|
|
|
u32 m_maxProgress = 1;
|
|
|
|
|
|
|
|
bool m_temporary = false;
|
|
|
|
|
|
|
|
friend class AchievementManager;
|
|
|
|
};
|
|
|
|
|
|
|
|
class AchievementManager {
|
|
|
|
public:
|
|
|
|
AchievementManager() = delete;
|
|
|
|
|
|
|
|
struct AchievementNode {
|
|
|
|
Achievement *achievement;
|
|
|
|
std::vector<AchievementNode*> children, parents;
|
|
|
|
std::vector<AchievementNode*> visibilityParents;
|
|
|
|
ImVec2 position;
|
|
|
|
|
|
|
|
[[nodiscard]] bool hasParents() const {
|
|
|
|
return !this->parents.empty();
|
|
|
|
}
|
|
|
|
|
|
|
|
[[nodiscard]] bool isUnlockable() const {
|
|
|
|
return std::all_of(this->parents.begin(), this->parents.end(), [](auto &parent) { return parent->achievement->isUnlocked(); });
|
|
|
|
}
|
|
|
|
|
|
|
|
[[nodiscard]] bool isVisible() const {
|
|
|
|
return std::all_of(this->visibilityParents.begin(), this->visibilityParents.end(), [](auto &parent) { return parent->achievement->isUnlocked(); });
|
|
|
|
}
|
|
|
|
|
|
|
|
[[nodiscard]] bool isUnlocked() const {
|
|
|
|
return this->achievement->isUnlocked();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Adds a new achievement
|
|
|
|
* @tparam T Type of the achievement
|
|
|
|
* @param args Arguments to pass to the constructor of the achievement
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
template<std::derived_from<Achievement> T = Achievement>
|
|
|
|
static Achievement& addAchievement(auto && ... args) {
|
|
|
|
auto newAchievement = std::make_unique<T>(std::forward<decltype(args)>(args)...);
|
|
|
|
|
2024-02-10 23:31:05 +01:00
|
|
|
return addAchievementImpl(std::move(newAchievement));
|
2023-08-06 21:33:15 +02:00
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Adds a new temporary achievement
|
|
|
|
* @tparam T Type of the achievement
|
|
|
|
* @param args Arguments to pass to the constructor of the achievement
|
|
|
|
* @return Reference to the achievement
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
template<std::derived_from<Achievement> T = Achievement>
|
|
|
|
static Achievement& addTemporaryAchievement(auto && ... args) {
|
2023-11-10 20:47:08 +01:00
|
|
|
auto &achievement = addAchievement<T>(std::forward<decltype(args)>(args)...);
|
2023-08-06 21:33:15 +02:00
|
|
|
|
|
|
|
achievement.m_temporary = true;
|
|
|
|
|
|
|
|
return achievement;
|
|
|
|
}
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Unlocks an achievement
|
|
|
|
* @param unlocalizedCategory Unlocalized category of the achievement
|
|
|
|
* @param unlocalizedName Unlocalized name of the achievement
|
|
|
|
*/
|
2023-12-19 12:22:28 +01:00
|
|
|
static void unlockAchievement(const UnlocalizedString &unlocalizedCategory, const UnlocalizedString &unlocalizedName);
|
2023-08-06 21:33:15 +02:00
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns all registered achievements
|
|
|
|
* @return All achievements
|
|
|
|
*/
|
2024-02-10 23:31:05 +01:00
|
|
|
static const std::unordered_map<std::string, std::unordered_map<std::string, std::unique_ptr<Achievement>>>& getAchievements();
|
2023-08-06 21:33:15 +02:00
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Returns all achievement start nodes
|
|
|
|
* @note Start nodes are all nodes that don't have any parents
|
|
|
|
* @param rebuild Whether to rebuild the list of start nodes
|
|
|
|
* @return All achievement start nodes
|
|
|
|
*/
|
2024-02-10 23:31:05 +01:00
|
|
|
static const std::unordered_map<std::string, std::vector<AchievementNode*>>& getAchievementStartNodes(bool rebuild = true);
|
2023-08-26 12:54:52 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @brief Returns all achievement nodes
|
|
|
|
* @param rebuild Whether to rebuild the list of nodes
|
|
|
|
* @return All achievement nodes
|
|
|
|
*/
|
2024-02-10 23:31:05 +01:00
|
|
|
static const std::unordered_map<std::string, std::list<AchievementNode>>& getAchievementNodes(bool rebuild = true);
|
2023-08-06 21:33:15 +02:00
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Loads the progress of all achievements from the achievements save file
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
static void loadProgress();
|
2023-08-26 12:54:52 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @brief Stores the progress of all achievements to the achievements save file
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
static void storeProgress();
|
|
|
|
|
2023-08-26 12:54:52 +02:00
|
|
|
/**
|
|
|
|
* @brief Removes all temporary achievements from the tree
|
|
|
|
*/
|
2023-08-06 21:33:15 +02:00
|
|
|
static void clearTemporary();
|
|
|
|
|
2023-11-16 13:23:28 +01:00
|
|
|
/**
|
|
|
|
* \brief Returns the current progress of all achievements
|
|
|
|
* \return A pair containing the number of unlocked achievements and the total number of achievements
|
|
|
|
*/
|
|
|
|
static std::pair<u32, u32> getProgress();
|
|
|
|
|
2023-08-06 21:33:15 +02:00
|
|
|
private:
|
|
|
|
static void achievementAdded();
|
2024-02-10 23:31:05 +01:00
|
|
|
|
|
|
|
static Achievement& addAchievementImpl(std::unique_ptr<Achievement> &&newAchievement);
|
2023-08-06 21:33:15 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
}
|