KytyPS5/tests/TrophySystemTests.cpp

262 lines
11 KiB
C++

#include "common/file.h"
#include "common/trophies.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <map>
#include <string>
#include <vector>
namespace {
namespace Trophies = Common::Trophies;
void Check(bool value, const char *text) {
if (!value) {
std::fprintf(stderr, "TrophySystemTests: failed: %s\n", text);
std::abort();
}
}
class TempDirectory {
public:
TempDirectory() {
const auto unique =
std::chrono::steady_clock::now().time_since_epoch().count();
m_path = std::filesystem::temp_directory_path() /
("kyty_trophy_test_" + std::to_string(unique));
Check(std::filesystem::create_directories(m_path),
"create temporary directory");
}
~TempDirectory() {
std::error_code error;
std::filesystem::remove_all(m_path, error);
}
[[nodiscard]] const std::filesystem::path &Path() const { return m_path; }
private:
std::filesystem::path m_path;
};
void WriteBigEndian(std::vector<char> &bytes, size_t offset, uint64_t value,
size_t length) {
Check(offset <= bytes.size() && length <= bytes.size() - offset,
"write within package");
for (size_t i = 0; i < length; ++i) {
bytes[offset + length - 1 - i] = static_cast<char>(value & 0xff);
value >>= 8;
}
}
void WritePackage(const std::filesystem::path &path,
const std::map<std::string, std::string> &files) {
size_t offset = 0x40 + files.size() * 0x40;
size_t size = offset;
for (const auto &[name, contents] : files)
size += contents.size();
std::vector<char> bytes(size);
WriteBigEndian(bytes, 0, 0xb228c60a, 4);
WriteBigEndian(bytes, 4, 1, 4);
WriteBigEndian(bytes, 8, bytes.size(), 8);
WriteBigEndian(bytes, 0x10, files.size(), 4);
WriteBigEndian(bytes, 0x14, 0x20, 4);
size_t entry = 0x40;
for (const auto &[name, contents] : files) {
Check(name.size() < 0x20, "fixture file name fits");
std::memcpy(bytes.data() + entry, name.data(), name.size());
WriteBigEndian(bytes, entry + 0x20, offset, 8);
WriteBigEndian(bytes, entry + 0x28, contents.size(), 8);
std::memcpy(bytes.data() + offset, contents.data(), contents.size());
offset += contents.size();
entry += 0x40;
}
Common::File file;
Check(file.Create(path), "create package fixture");
uint32_t written = 0;
file.Write(bytes.data(), static_cast<uint32_t>(bytes.size()), &written);
Check(written == bytes.size(), "write package fixture");
}
Trophies::Package MakePackage(const std::filesystem::path &directory) {
const auto path = directory / "trophy00.ucp";
WritePackage(
path,
{{"tropconf.json", R"({"defaultLanguage":"en-US","trophies":[
{"id":"0","grade":"P"},
{"id":"1","grade":"G","unlockCondition":{"udsStatId":"10","comparator":"ge","targetValue":"10","progressive":false}},
{"id":"2","grade":"S","unlockCondition":{"udsStatId":"20","comparator":"gt","targetValue":"100","progressive":false}},
{"id":"3","grade":"B","groupId":"1","unlockCondition":{"udsStatId":"30","comparator":"le","targetValue":"5","progressive":false}},
{"id":"4","grade":"B","unlockCondition":{"udsStatId":"10","comparator":"lt","targetValue":"-1","progressive":false}}
]})"},
{"tropmeta_en-US.json",
R"({"metadata":{"titleMetadata":{"name":"Test game"},"trophyMetadata":[
{"id":"1","name":"Find All Coins","detail":"Find all coins in the level."}
]}})"}});
return Trophies::LoadPackage(path, 1);
}
void TestEventExtraction(const std::filesystem::path &directory) {
auto package = MakePackage(directory);
Check(package.trophies.size() == 5,
"unsupported signed target does not discard trophy metadata");
Check(package.trophies.at(1).uds_stat_id == 10 &&
package.trophies.at(1).target == 10 &&
!package.trophies.at(1).progressive,
"non-progressive trophy retains its stat target");
Check(!package.trophies.at(4).target,
"negative target is not converted to unsigned");
Check(Trophies::FindUdsTrophies(package, "FIND_ALL_COINS",
{{"counter", uint64_t{100}}})
.empty(),
"missing mapping never guesses from trophy text");
const auto path = directory / "uds00.ucp";
WritePackage(path, {{"stats_definition.json", R"({"statDefinitionArray":[
{"statId":10,"dataType":"uint64","aggregation":"latest","minValue":"0"},
{"statId":20,"dataType":"uint64","aggregation":"latest"},
{"statId":30,"dataType":"uint32","aggregation":"latest"},
{"statId":40,"dataType":"uint64","aggregation":"sum"},
{"statId":50,"dataType":"uint64","aggregation":"count"},
{"statId":60,"dataType":"uint64","aggregation":"max"},
{"statId":70,"dataType":"uint64","aggregation":"latest","maxValue":"3"}
]})"},
{"stats_extraction.json", R"({"statsExtractionRuleArray":[
{"condition":{"eventName":"COLLECT"},"action":{"input":"$.counter","output":{"statId":10}}},
{"condition":{"eventName":"COLLECT"},"action":{"input":"$.counterBase","output":{"statId":20}}},
{"condition":{"eventName":"TIME"},"action":{"input":"$.seconds","output":{"statId":30}}},
{"condition":{"eventName":"FILTERED","property":{"path":"$.level","comparator":"==","value":1}},"action":{"input":"$.counter","output":{"statId":10}}},
{"condition":{"eventName":"NESTED"},"action":{"input":"$.player.counter","output":{"statId":10}}},
{"condition":{"eventName":"SUM"},"action":{"input":"$.counter","output":{"statId":40}}},
{"condition":{"eventName":"COUNT"},"action":{"output":{"statId":50}}},
{"condition":{"eventName":"MAX"},"action":{"input":"$.counter","output":{"statId":60}}},
{"condition":{"eventName":"BOUNDED"},"action":{"input":"$.counter","output":{"statId":70}}},
{"bad":1}
]})"}});
package.event_rules = Trophies::LoadUdsRules(path);
Check(package.event_rules.size() == 2,
"skip unsupported extraction and aggregation");
Check(Trophies::FindUdsTrophies(
package, "COLLECT",
{{"counter", uint64_t{9}}, {"counterBase", uint64_t{100}}})
.empty(),
"do not add independent counters or treat gt as ge");
Check(Trophies::FindUdsTrophies(
package, "COLLECT",
{{"counter", uint64_t{10}}, {"counterBase", uint64_t{100}}}) ==
std::vector<int>{1},
"each stat reads its configured property");
Check(Trophies::FindUdsTrophies(
package, "COLLECT",
{{"counter", uint64_t{0}}, {"counterBase", uint64_t{101}}}) ==
std::vector<int>{2},
"strict comparison requires greater value");
Check(Trophies::FindUdsTrophies(
package, "COLLECT",
{{"counter", uint64_t{10}}, {"counterBase", uint64_t{101}}}) ==
std::vector<int>({1, 2}),
"one event can unlock multiple mapped trophies");
Check(Trophies::FindUdsTrophies(
package, "TIME", {{"seconds", uint32_t{5}}}) == std::vector<int>{3},
"less-equal condition accepts its boundary");
Check(Trophies::FindUdsTrophies(package, "TIME", {{"seconds", uint32_t{6}}})
.empty(),
"less-equal condition rejects larger input");
Check(Trophies::FindUdsTrophies(package, "TIME", {{"counter", uint64_t{0}}})
.empty(),
"missing property is not zero");
package.trophies.at(3).comparison = Trophies::Comparison::Less;
Check(Trophies::FindUdsTrophies(package, "TIME", {{"seconds", uint32_t{5}}})
.empty(),
"strict less rejects its boundary");
Check(Trophies::FindUdsTrophies(
package, "TIME", {{"seconds", uint32_t{4}}}) == std::vector<int>{3},
"strict less accepts lower input");
package.trophies.at(3).comparison = Trophies::Comparison::GreaterEqual;
Check(Trophies::FindUdsTrophies(package, "TIME",
{{"seconds", uint64_t{UINT32_MAX} + 1}})
.empty(),
"uint64 input cannot update a uint32 stat");
Check(
Trophies::FindUdsTrophies(package, "COLLECT", {{"counter", int32_t{100}}})
.empty(),
"signed inputs cannot update unsigned stats");
Check(Trophies::FindUdsTrophies(package, "COLLECT",
{{"counter", uint32_t{100}}})
.empty(),
"unsigned input width must match the stat");
package.trophies.at(1).comparison = Trophies::Comparison::None;
Check(Trophies::FindUdsTrophies(package, "COLLECT",
{{"counter", uint64_t{100}}})
.empty(),
"unknown comparator cannot unlock");
WritePackage(path, {{"stats_extraction.json", "{}"}});
Check(Trophies::LoadUdsRules(path).empty(),
"missing stat definitions cannot infer aggregation");
Check(Trophies::LoadUdsRules(directory / "missing.ucp").empty(),
"missing UDS package has no rules");
}
void TestProgress(const std::filesystem::path &directory) {
const auto package = MakePackage(directory);
Trophies::UnlockData unlocks;
unlocks.unlocked = {0, 1, 999};
const auto progress = Trophies::GetProgress(package, unlocks);
Check(progress.total == 5 && progress.earned == 2,
"ignore unlock IDs absent from package");
Check(progress.earned_grade[1] == 1 && progress.earned_grade[2] == 1 &&
progress.Percentage() == 60,
"percentage uses 6:2:1 grade points and excludes platinum");
const auto group = Trophies::GetProgress(package, unlocks, 1);
Check(group.total == 1 && group.earned == 0 && group.Percentage() == 0,
"group counts filter trophies");
Check(Trophies::GetProgress({}, {}).Percentage() == 0,
"empty package progress is zero");
}
void TestUnlockPersistence(const std::filesystem::path &directory) {
const auto path = Trophies::UnlocksPath(directory, "PPSA00001", 1000, 0);
Check(path == directory / "_Trophies/PPSA00001/trophies_1000_0.json",
"store trophies outside save data");
Check(Trophies::UnlocksPath(directory, "../bad", 1000, 0).empty(),
"reject unsafe title IDs");
Check(Trophies::LoadUnlockData(path).unlocked.empty(),
"missing unlock file loads as empty");
Trophies::UnlockData unlocks;
unlocks.unlocked = {4, 42};
const auto tick = Trophies::UnixEpochTick + 1791288000123456ULL;
unlocks.timestamps.emplace(42, tick);
Check(Trophies::SaveUnlockData(path, unlocks), "save timestamped unlocks");
auto loaded = Trophies::LoadUnlockData(path);
Check(loaded.unlocked == unlocks.unlocked &&
loaded.timestamps == unlocks.timestamps,
"persist full UTC microsecond timestamp");
loaded.unlocked.insert(43);
loaded.timestamps.emplace(43, tick + 1000000);
Check(Trophies::SaveUnlockData(path, loaded), "replace existing unlock file");
Check(Trophies::LoadUnlockData(path).timestamps.at(42) == tick,
"retain original unlock time");
const auto blocked = directory / "blocked.json";
std::filesystem::create_directory(blocked);
Common::File sentinel;
Check(sentinel.Create(blocked / "keep"),
"create replacement failure fixture");
sentinel.Close();
Check(!Trophies::SaveUnlockData(blocked, loaded) &&
std::filesystem::exists(blocked / "keep"),
"failed replacement leaves destination intact");
Check(!std::filesystem::exists(directory / "blocked.json.tmp"),
"failed replacement removes temporary file");
}
} // namespace
int main() {
TempDirectory directory;
TestEventExtraction(directory.Path());
TestProgress(directory.Path());
TestUnlockPersistence(directory.Path());
std::printf("TrophySystemTests: all cases passed\n");
return 0;
}