|
@@ -0,0 +1,532 @@
|
|
|
|
|
+#include <algorithm>
|
|
|
|
|
+#include <cassert>
|
|
|
|
|
+#include <cctype>
|
|
|
|
|
+#include <cstring>
|
|
|
|
|
+#include <filesystem>
|
|
|
|
|
+#include <fstream>
|
|
|
|
|
+#include <iostream>
|
|
|
|
|
+#include <iterator>
|
|
|
|
|
+#include <limits>
|
|
|
|
|
+#include <sstream>
|
|
|
|
|
+#include <vector>
|
|
|
|
|
+
|
|
|
|
|
+#include "middleware/content/packages/tables/definition_index_table.h"
|
|
|
|
|
+#include "middleware/content/packages/tables/quest_initialization_reader.h"
|
|
|
|
|
+#include "middleware/datagen/definitions.h"
|
|
|
|
|
+#include "middleware/datagen/family4/account/account_encoder.h"
|
|
|
|
|
+#include "middleware/datagen/family4/account/layout.h"
|
|
|
|
|
+#include "middleware/datagen/family4/character/character_encoder.h"
|
|
|
|
|
+#include "middleware/datagen/family4/character/layout.h"
|
|
|
|
|
+#include "middleware/datagen/family4/loadout/loadout_resolver.h"
|
|
|
|
|
+#include "server/bap/encrypted/queuez/queuez_state_validation.h"
|
|
|
|
|
+#include "state/build_data/cache/records/codec.h"
|
|
|
|
|
+#include "state/build_data/inventory/buckets/inventory_bucket_catalog.h"
|
|
|
|
|
+#include "state/build_data/items/details/item_detail_catalog.h"
|
|
|
|
|
+#include "state/build_data/items/item_catalog.h"
|
|
|
|
|
+#include "state/build_data/progressions/progression_catalog.h"
|
|
|
|
|
+#include "state/build_data/runtime/domain_markers.h"
|
|
|
|
|
+#include "state/build_data/socket_entry_lists/socket_entry_list_catalog.h"
|
|
|
|
|
+#include "state/investment/store_internal.h"
|
|
|
|
|
+#include "state/runtime/runtime.h"
|
|
|
|
|
+
|
|
|
|
|
+namespace fs = std::filesystem;
|
|
|
|
|
+namespace s = sunrise::state;
|
|
|
|
|
+namespace items = s::build_data::items;
|
|
|
|
|
+namespace tables = sunrise::middleware::content::packages::tables;
|
|
|
|
|
+namespace store = s::investment::store;
|
|
|
|
|
+using Quest = items::QuestInitialization;
|
|
|
|
|
+using Bytes = std::vector<std::byte>;
|
|
|
|
|
+
|
|
|
|
|
+std::string read_text(const fs::path& path) {
|
|
|
|
|
+ std::ifstream file(path, std::ios::binary);
|
|
|
|
|
+ assert(file.good());
|
|
|
|
|
+ return {std::istreambuf_iterator<char>(file), {}};
|
|
|
|
|
+}
|
|
|
|
|
+Bytes read_bytes(const fs::path& path) {
|
|
|
|
|
+ const auto text = read_text(path);
|
|
|
|
|
+ Bytes bytes(text.size());
|
|
|
|
|
+ std::memcpy(bytes.data(), text.data(), text.size());
|
|
|
|
|
+ return bytes;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Source-level wiring guard, not an end-to-end world-reward delivery test.
|
|
|
|
|
+// Keep the real caller covered without requiring the game's compression DLL.
|
|
|
|
|
+void world_reward_caller_check(const fs::path& repository) {
|
|
|
|
|
+ auto source =
|
|
|
|
|
+ read_text(repository / "Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp");
|
|
|
|
|
+ std::erase_if(source, [](unsigned char c) { return std::isspace(c) != 0; });
|
|
|
|
|
+ const auto function = source.find("boolconsume_world_item_acquisition(");
|
|
|
|
|
+ assert(function != std::string::npos);
|
|
|
|
|
+ const auto call = source.find("queuez::stage_item_acquisition(", function);
|
|
|
|
|
+ const auto end = source.find("acquisition))", call);
|
|
|
|
|
+ assert(call != std::string::npos && end != std::string::npos);
|
|
|
|
|
+ auto arguments = source.substr(call, end + std::strlen("acquisition))") - call);
|
|
|
|
|
+ constexpr auto expected = "queuez::stage_item_acquisition(session.queuez,pending.accountSoid,"
|
|
|
|
|
+ "pending.characterSoid,pending.acquiredInstanceSoid,"
|
|
|
|
|
+ "pending.updates_account(),acquisition))";
|
|
|
|
|
+ assert(arguments == expected);
|
|
|
|
|
+ // Negative control: restoring the old argument must fail this wiring check.
|
|
|
|
|
+ const auto flag = arguments.find("pending.updates_account()");
|
|
|
|
|
+ arguments.replace(flag, std::strlen("pending.updates_account()"), "pending.profileChanged");
|
|
|
|
|
+ assert(arguments != expected);
|
|
|
|
|
+ std::cout << "PASS world-reward caller source guard (old argument rejected)\n";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void check_acquisition_staging(const s::PendingItemAcquisition& mutation, bool updatesAccount) {
|
|
|
|
|
+ namespace queuez = sunrise::server::bap::encrypted::queuez;
|
|
|
|
|
+ namespace datagen = sunrise::middleware::datagen;
|
|
|
|
|
+ queuez::SessionState before{};
|
|
|
|
|
+ before.family4Active = true;
|
|
|
|
|
+ before.family4RootSoid = mutation.accountSoid;
|
|
|
|
|
+ before.family4ResidentCount = 2;
|
|
|
|
|
+ before.family4Residents[0] = {mutation.accountSoid, datagen::kAccountObjectId};
|
|
|
|
|
+ before.family4Residents[1] = {mutation.characterSoid, datagen::kCharacterObjectId};
|
|
|
|
|
+ queuez::ItemAcquisition staged{};
|
|
|
|
|
+ assert(queuez::stage_item_acquisition(before,
|
|
|
|
|
+ mutation.accountSoid,
|
|
|
|
|
+ mutation.characterSoid,
|
|
|
|
|
+ mutation.acquiredInstanceSoid,
|
|
|
|
|
+ mutation.updates_account(),
|
|
|
|
|
+ staged));
|
|
|
|
|
+ assert(staged.updatesAccount == updatesAccount);
|
|
|
|
|
+ assert(staged.accountDefinitionId == datagen::kAccountObjectId);
|
|
|
|
|
+ assert(staged.characterDefinitionId == datagen::kCharacterObjectId);
|
|
|
|
|
+ assert(staged.itemInstanceDefinitionId == datagen::kItemInstanceObjectId);
|
|
|
|
|
+ assert(staged.after.family4Version == before.family4Version + 1);
|
|
|
|
|
+ assert(staged.after.family4ResidentCount == before.family4ResidentCount + 1);
|
|
|
|
|
+ assert(staged.after.family4Residents[2].objectSoid == mutation.acquiredInstanceSoid);
|
|
|
|
|
+ assert(before.family4Version == 0 && before.family4ResidentCount == 2);
|
|
|
|
|
+}
|
|
|
|
|
+template <class T> void put(Bytes& bytes, std::size_t at, T value) {
|
|
|
|
|
+ assert(at <= bytes.size() && sizeof value <= bytes.size() - at);
|
|
|
|
|
+ std::memcpy(bytes.data() + at, &value, sizeof value);
|
|
|
|
|
+}
|
|
|
|
|
+void block(Bytes& bytes, std::size_t field, std::size_t at, std::uint32_t cls) {
|
|
|
|
|
+ put(bytes, field, static_cast<std::int64_t>(at) - static_cast<std::int64_t>(field));
|
|
|
|
|
+ put(bytes, at - 4, cls);
|
|
|
|
|
+}
|
|
|
|
|
+void array(
|
|
|
|
|
+ Bytes& bytes, std::size_t field, std::size_t at, std::uint64_t count, std::uint32_t cls) {
|
|
|
|
|
+ put(bytes, field, count);
|
|
|
|
|
+ put(bytes, field + 8, static_cast<std::int64_t>(at) - static_cast<std::int64_t>(field + 8));
|
|
|
|
|
+ put(bytes, at - 4, std::uint32_t{0x80800000});
|
|
|
|
|
+ put(bytes, at, count);
|
|
|
|
|
+ put(bytes, at + 8, cls);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+Quest parser_checks() {
|
|
|
|
|
+ Bytes item(800), map(256);
|
|
|
|
|
+ put(item, 184, std::uint8_t{40});
|
|
|
|
|
+ block(item, 0x30, 260, 0x808077EB);
|
|
|
|
|
+ put(item, 288, std::uint16_t{0});
|
|
|
|
|
+ array(item, 260, 500, 1, 0x808087B1);
|
|
|
|
|
+ block(item, 0x60, 320, 0x808077C8);
|
|
|
|
|
+ put(item, 336, std::uint16_t{7});
|
|
|
|
|
+ put(item, 348, std::uint8_t{1});
|
|
|
|
|
+ array(item, 320, 550, 2, 0x808077CA);
|
|
|
|
|
+ put(item, 566, std::int32_t{-123});
|
|
|
|
|
+ put(item, 570, std::uint16_t{0});
|
|
|
|
|
+ put(item, 574, std::int32_t{42});
|
|
|
|
|
+ put(item, 578, std::uint16_t{1});
|
|
|
|
|
+ block(item, 0x90, 380, 0x808077AB);
|
|
|
|
|
+ array(item, 380, 600, 1, 0x80807D4B);
|
|
|
|
|
+ put(item, 616, std::uint16_t{11});
|
|
|
|
|
+ array(map, 8, 100, 1, 0x80800001);
|
|
|
|
|
+ put(map, 120, std::int16_t{7});
|
|
|
|
|
+ const Quest expected{-123, 0, Quest::Scope::account};
|
|
|
|
|
+ const auto parse = [&](const Bytes& a, const Bytes& m) {
|
|
|
|
|
+ return tables::items::read_quest_initialization(a, 0, a, 2, m);
|
|
|
|
|
+ };
|
|
|
|
|
+ assert(parse(item, map) == expected);
|
|
|
|
|
+ assert(items::initialized_value(expected, 0) == -123);
|
|
|
|
|
+ for (auto before : {100, 200, -1, -1583618456}) {
|
|
|
|
|
+ assert(items::initialized_value(expected, before) == before);
|
|
|
|
|
+ }
|
|
|
|
|
+ assert(!items::valid(Quest{100, 6200, Quest::Scope::account}));
|
|
|
|
|
+ assert(!items::valid(Quest{100, 768, Quest::Scope::character}));
|
|
|
|
|
+ auto bad = item;
|
|
|
|
|
+ put(bad, 0x60, (std::numeric_limits<std::int64_t>::max)());
|
|
|
|
|
+ assert(parse(bad, map) == Quest{});
|
|
|
|
|
+ bad = item;
|
|
|
|
|
+ put(bad, 578, std::uint16_t{0});
|
|
|
|
|
+ assert(parse(bad, map) == Quest{}); // Duplicate membership.
|
|
|
|
|
+ bad = item;
|
|
|
|
|
+ put(bad, 574, std::int32_t{-123});
|
|
|
|
|
+ assert(parse(bad, map) == Quest{}); // Ambiguous initial identifier.
|
|
|
|
|
+ auto separate = item;
|
|
|
|
|
+ put(separate, 288, std::uint16_t{1});
|
|
|
|
|
+ put(separate, 0x60, std::int64_t{0});
|
|
|
|
|
+ assert(tables::items::read_quest_initialization(separate, 0, item, 2, map) == expected);
|
|
|
|
|
+ bad = item;
|
|
|
|
|
+ put(bad, 348, std::uint8_t{0});
|
|
|
|
|
+ assert(parse(bad, map) == Quest{});
|
|
|
|
|
+ bad = item;
|
|
|
|
|
+ put(bad, 0x90, std::int64_t{0});
|
|
|
|
|
+ assert(parse(bad, map) == Quest{});
|
|
|
|
|
+ bad = item;
|
|
|
|
|
+ bad.resize(240);
|
|
|
|
|
+ assert(parse(bad, map) == Quest{});
|
|
|
|
|
+ for (auto value : {0, -1}) {
|
|
|
|
|
+ bad = item;
|
|
|
|
|
+ put(bad, 566, value);
|
|
|
|
|
+ assert(parse(bad, map) == Quest{});
|
|
|
|
|
+ }
|
|
|
|
|
+ assert(tables::items::read_quest_initialization(item, 1, item, 2, map) == Quest{});
|
|
|
|
|
+ auto duplicateMap = map;
|
|
|
|
|
+ array(duplicateMap, 24, 160, 1, 0x80800001);
|
|
|
|
|
+ put(duplicateMap, 180, std::int16_t{7});
|
|
|
|
|
+ assert(parse(item, duplicateMap) == Quest{});
|
|
|
|
|
+ auto characterMap = map;
|
|
|
|
|
+ put(characterMap, 8, std::uint64_t{0});
|
|
|
|
|
+ put(characterMap, 16, std::int64_t{0});
|
|
|
|
|
+ array(characterMap, 24, 160, 1, 0x80800001);
|
|
|
|
|
+ put(characterMap, 180, std::int16_t{7});
|
|
|
|
|
+ assert((parse(item, characterMap) == Quest{-123, 0, Quest::Scope::character}));
|
|
|
|
|
+ auto contextMap = map;
|
|
|
|
|
+ put(contextMap, 8, std::uint64_t{0});
|
|
|
|
|
+ put(contextMap, 16, std::int64_t{0});
|
|
|
|
|
+ array(contextMap, 40, 160, 1, 0x80800001);
|
|
|
|
|
+ put(contextMap, 180, std::int16_t{7});
|
|
|
|
|
+ assert(parse(item, contextMap) == Quest{});
|
|
|
|
|
+ auto root = item;
|
|
|
|
|
+ put(root, 184, std::uint8_t{37});
|
|
|
|
|
+ put(root, 0x30, std::int64_t{0});
|
|
|
|
|
+ put(root, 566, std::int32_t{-1583618456});
|
|
|
|
|
+ auto noFlags = separate;
|
|
|
|
|
+ put(noFlags, 380, std::uint64_t{0});
|
|
|
|
|
+ put(noFlags, 388, std::int64_t{0});
|
|
|
|
|
+ Bytes rootMap(3800);
|
|
|
|
|
+ array(rootMap, 24, 100, 443, 0x80800001);
|
|
|
|
|
+ put(rootMap, 116 + 442 * 8 + 4, std::int16_t{7});
|
|
|
|
|
+ const auto rootParse = [&](const Bytes& a, const Bytes& p) {
|
|
|
|
|
+ return tables::items::read_quest_initialization(a, 0, p, 2, rootMap);
|
|
|
|
|
+ };
|
|
|
|
|
+ const Quest rootQuest{-1583618456, 442, Quest::Scope::character};
|
|
|
|
|
+ assert(rootParse(noFlags, root) == rootQuest);
|
|
|
|
|
+ auto absent = noFlags;
|
|
|
|
|
+ put(absent, 0x90, std::int64_t{0});
|
|
|
|
|
+ assert(rootParse(absent, root) == rootQuest);
|
|
|
|
|
+ auto malformed = noFlags;
|
|
|
|
|
+ put(malformed, 0x90, (std::numeric_limits<std::int64_t>::max)());
|
|
|
|
|
+ assert(rootParse(malformed, root) == Quest{});
|
|
|
|
|
+ malformed = noFlags;
|
|
|
|
|
+ put(malformed, 380, std::uint64_t{1}); // Nonempty flags need a valid array.
|
|
|
|
|
+ assert(rootParse(malformed, root) == Quest{});
|
|
|
|
|
+ auto wrongRoot = root;
|
|
|
|
|
+ put(wrongRoot, 184, std::uint8_t{40});
|
|
|
|
|
+ assert(rootParse(noFlags, wrongRoot) == Quest{});
|
|
|
|
|
+ wrongRoot = root;
|
|
|
|
|
+ put(wrongRoot, 0x30, std::int64_t{212});
|
|
|
|
|
+ assert(rootParse(noFlags, wrongRoot) == Quest{});
|
|
|
|
|
+ wrongRoot = root;
|
|
|
|
|
+ put(wrongRoot, 570, std::uint16_t{1});
|
|
|
|
|
+ put(wrongRoot, 578, std::uint16_t{0}); // Later members stay unsupported.
|
|
|
|
|
+ assert(rootParse(noFlags, wrongRoot) == Quest{});
|
|
|
|
|
+ assert(tables::items::read_quest_initialization(noFlags, 0, root, 2, map) == Quest{});
|
|
|
|
|
+ std::cout << "PASS synthetic parser, signed values, bounds, ambiguity and preservation\n";
|
|
|
|
|
+ std::cout << "PASS separate-root first step: empty/absent flags, malformed inputs, "
|
|
|
|
|
+ "wrong root/scope and later-step rejection\n";
|
|
|
|
|
+ return rootQuest;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void content_checks(const fs::path& root) {
|
|
|
|
|
+ struct Row {
|
|
|
|
|
+ std::uint32_t hash{}, tag{};
|
|
|
|
|
+ unsigned bucket{};
|
|
|
|
|
+ bool objective{}, set{};
|
|
|
|
|
+ };
|
|
|
|
|
+ std::vector<Row> rows;
|
|
|
|
|
+ std::istringstream input(read_text(root / "items.tsv"));
|
|
|
|
|
+ std::string line;
|
|
|
|
|
+ std::getline(input, line);
|
|
|
|
|
+ while (std::getline(input, line)) {
|
|
|
|
|
+ std::istringstream fields(line);
|
|
|
|
|
+ std::size_t index = 0;
|
|
|
|
|
+ Row row;
|
|
|
|
|
+ fields >> index >> row.hash >> row.tag >> row.bucket >> row.objective >> row.set;
|
|
|
|
|
+ assert(fields && index == rows.size());
|
|
|
|
|
+ rows.push_back(row);
|
|
|
|
|
+ }
|
|
|
|
|
+ auto blob = [&](std::size_t index) {
|
|
|
|
|
+ std::ostringstream filename;
|
|
|
|
|
+ filename << std::uppercase << std::hex << rows.at(index).tag << ".bin";
|
|
|
|
|
+ return read_bytes(root / filename.str());
|
|
|
|
|
+ };
|
|
|
|
|
+ const auto map = read_bytes(root / "81319320.bin");
|
|
|
|
|
+ std::vector<Quest> quests(rows.size());
|
|
|
|
|
+ std::size_t account = 0, character = 0;
|
|
|
|
|
+ for (std::size_t i = 0; i < rows.size(); ++i) {
|
|
|
|
|
+ if (rows[i].bucket != 40 || !rows[i].objective) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const auto item = blob(i);
|
|
|
|
|
+ const auto parent = tables::items::quest_parent(item);
|
|
|
|
|
+ if (parent >= rows.size()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ quests[i] = tables::items::read_quest_initialization(
|
|
|
|
|
+ item, static_cast<std::uint16_t>(i), blob(parent), rows.size(), map);
|
|
|
|
|
+ account += quests[i].scope == Quest::Scope::account;
|
|
|
|
|
+ character += quests[i].scope == Quest::Scope::character;
|
|
|
|
|
+ }
|
|
|
|
|
+ assert((quests.at(15282) == Quest{100, 5762, Quest::Scope::account}));
|
|
|
|
|
+ assert((quests.at(14844) == Quest{100, 442, Quest::Scope::character}));
|
|
|
|
|
+ assert((quests.at(13138) == Quest{-1583618456, 162, Quest::Scope::character}));
|
|
|
|
|
+ for (auto index : {15U, 13139U, 13140U, 13141U, 13142U, 13143U, 14845U, 15283U}) {
|
|
|
|
|
+ assert(quests.at(index) == Quest{});
|
|
|
|
|
+ }
|
|
|
|
|
+ assert(account == 50 && character >= 133);
|
|
|
|
|
+ std::cout << "PASS installed content: " << account << " account + " << character
|
|
|
|
|
+ << " character first steps (structural coverage, not gameplay certification)\n";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void cache_checks() {
|
|
|
|
|
+ namespace cache = s::build_data::cache::records;
|
|
|
|
|
+ items::Definition item{};
|
|
|
|
|
+ item.definitionHash = 123;
|
|
|
|
|
+ item.bucketId = 40;
|
|
|
|
|
+ item.questInitialization = {-123, 7, Quest::Scope::character};
|
|
|
|
|
+ cache::ItemRecord record{};
|
|
|
|
|
+ items::Definition decoded{};
|
|
|
|
|
+ assert(cache::encode(item, record) && cache::decode(record, decoded));
|
|
|
|
|
+ assert(decoded.questInitialization == item.questInitialization);
|
|
|
|
|
+ record.questValueScope = 255;
|
|
|
|
|
+ assert(!cache::decode(record, decoded));
|
|
|
|
|
+ std::cout << "PASS cache round trip and invalid scope rejection\n";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void runtime_checks(const fs::path& repo, const Quest& characterQuest) {
|
|
|
|
|
+ const auto resources = repo / "Sunrise/resources/database";
|
|
|
|
|
+ assert(store::open(":memory:",
|
|
|
|
|
+ read_text(resources / "investment_schema.sql"),
|
|
|
|
|
+ read_text(resources / "investment_defaults.sql"),
|
|
|
|
|
+ read_text(resources / "account_settings_schema.sql"),
|
|
|
|
|
+ read_text(resources / "account_settings_defaults.sql")));
|
|
|
|
|
+ auto baseline = store::account();
|
|
|
|
|
+ baseline.profileItems = {};
|
|
|
|
|
+ baseline.profileItemCount = 0;
|
|
|
|
|
+ for (std::size_t i = 0; i < baseline.characterCount; ++i) {
|
|
|
|
|
+ auto& character = baseline.characters[i];
|
|
|
|
|
+ character.equipment = {};
|
|
|
|
|
+ character.inventory = {};
|
|
|
|
|
+ character.stacks = {};
|
|
|
|
|
+ character.selected = i == 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ assert(baseline.characterCount >= 2);
|
|
|
|
|
+ assert(store::write_account(baseline));
|
|
|
|
|
+ std::array<items::Definition, 7> definitions{};
|
|
|
|
|
+ std::array<items::details::Definition, 7> details{};
|
|
|
|
|
+ for (std::uint16_t i = 0; i < definitions.size(); ++i) {
|
|
|
|
|
+ definitions[i].definitionIndex = i;
|
|
|
|
|
+ definitions[i].definitionHash = 1000U + i;
|
|
|
|
|
+ definitions[i].bucketId = 40;
|
|
|
|
|
+ details[i].definitionIndex = i;
|
|
|
|
|
+ details[i].definitionHash = 1000U + i;
|
|
|
|
|
+ details[i].bucketId = 40;
|
|
|
|
|
+ details[i].maxStackSize = 1;
|
|
|
|
|
+ details[i].instancedDefinitionState = items::details::InstancedDefinitionState::instanced;
|
|
|
|
|
+ }
|
|
|
|
|
+ definitions[0].questInitialization = {100, 5762, Quest::Scope::account};
|
|
|
|
|
+ definitions[1].questInitialization = characterQuest;
|
|
|
|
|
+ // The existing character encoder independently requires these four legacy prerequisites.
|
|
|
|
|
+ constexpr std::array<std::uint32_t, 4> legacyQuests{
|
|
|
|
|
+ 0x57C4540AU, 0x85CC476EU, 0xB099029AU, 0xC3535D63U};
|
|
|
|
|
+ for (std::size_t i = 0; i < legacyQuests.size(); ++i) {
|
|
|
|
|
+ definitions[i + 3].definitionHash = details[i + 3].definitionHash = legacyQuests[i];
|
|
|
|
|
+ details[i + 3].instancedDefinitionState =
|
|
|
|
|
+ items::details::InstancedDefinitionState::stackable;
|
|
|
|
|
+ }
|
|
|
|
|
+ assert(items::replace(definitions));
|
|
|
|
|
+ assert(items::details::replace(details));
|
|
|
|
|
+ s::build_data::runtime::details::publish();
|
|
|
|
|
+ namespace buckets = s::build_data::inventory::buckets;
|
|
|
|
|
+ const std::array<buckets::Descriptor, 2> bucketRows{
|
|
|
|
|
+ {{0, buckets::ArraySelector::character, 0, 1, 0},
|
|
|
|
|
+ {40, buckets::ArraySelector::character, 1, 349}}};
|
|
|
|
|
+ assert(buckets::replace(bucketRows));
|
|
|
|
|
+ const s::build_data::socket_entry_lists::Definition sockets{1, 0, 0, 0};
|
|
|
|
|
+ assert(s::build_data::socket_entry_lists::replace({&sockets, 1}));
|
|
|
|
|
+ const s::build_data::progressions::Definition progression{};
|
|
|
|
|
+ assert(s::build_data::progressions::replace({&progression, 1}, {}));
|
|
|
|
|
+
|
|
|
|
|
+ const auto value = [](store::Bank bank, std::uint16_t row) {
|
|
|
|
|
+ std::int32_t result = 0;
|
|
|
|
|
+ assert(store::read_unlock(bank, row, result));
|
|
|
|
|
+ return result;
|
|
|
|
|
+ };
|
|
|
|
|
+ const auto reset = [&] {
|
|
|
|
|
+ assert(store::write_account(baseline));
|
|
|
|
|
+ assert(store::execute("DELETE FROM unlocks"));
|
|
|
|
|
+ };
|
|
|
|
|
+ s::PendingItemAcquisition mutation{};
|
|
|
|
|
+ s::AccountState after{};
|
|
|
|
|
+ s::unlocks::Table unlocks{};
|
|
|
|
|
+ const auto check_encoded = [&](bool accountScope, std::uint16_t row, std::int32_t expected) {
|
|
|
|
|
+ namespace family4 = sunrise::middleware::datagen::family4;
|
|
|
|
|
+ std::int32_t sent = 0;
|
|
|
|
|
+ if (accountScope) {
|
|
|
|
|
+ Bytes encoded(family4::account::layout::kObjectSize);
|
|
|
|
|
+ assert(family4::account::encode(after, encoded, unlocks));
|
|
|
|
|
+ std::memcpy(&sent,
|
|
|
|
|
+ encoded.data() + offsetof(family4::account::layout::Object, objectiveValues)
|
|
|
|
|
+ + row * sizeof sent,
|
|
|
|
|
+ sizeof sent);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Bytes encoded(family4::character::layout::kObjectSize);
|
|
|
|
|
+ family4::loadout::ResolvedLoadout resolved{};
|
|
|
|
|
+ assert(family4::loadout::resolve(after, 0, resolved));
|
|
|
|
|
+ s::equipment::light::Evaluation light{};
|
|
|
|
|
+ light.divisor = 1;
|
|
|
|
|
+ assert(
|
|
|
|
|
+ family4::character::encode(after.characters[0], resolved, light, encoded, unlocks));
|
|
|
|
|
+ std::memcpy(&sent,
|
|
|
|
|
+ encoded.data()
|
|
|
|
|
+ + offsetof(family4::character::layout::Object, objectiveValues)
|
|
|
|
|
+ + row * sizeof sent,
|
|
|
|
|
+ sizeof sent);
|
|
|
|
|
+ }
|
|
|
|
|
+ assert(sent == expected);
|
|
|
|
|
+ };
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(0, mutation));
|
|
|
|
|
+ assert(mutation.updates_account());
|
|
|
|
|
+ assert(!mutation.profileChanged); // The old world-reward flag misses this account write.
|
|
|
|
|
+ check_acquisition_staging(mutation, true);
|
|
|
|
|
+ assert(s::preview_item_acquisition(mutation, after, unlocks));
|
|
|
|
|
+ assert(unlocks.objectiveValues[5762] == 100);
|
|
|
|
|
+ check_encoded(true, 5762, 100);
|
|
|
|
|
+ assert(value(store::Bank::objectiveValues, 5762) == 0);
|
|
|
|
|
+ assert(store::account().characters[0].inventory.count == 0);
|
|
|
|
|
+ assert(s::commit_item_acquisition(mutation) && !mutation.prepared);
|
|
|
|
|
+ assert(value(store::Bank::objectiveValues, 5762) == 100);
|
|
|
|
|
+ assert(store::account().characters[0].inventory.count == 1);
|
|
|
|
|
+
|
|
|
|
|
+ for (auto previous : {100, 200, -1, -1583618456}) {
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(store::write_unlock(store::Bank::objectiveValues, 5762, previous));
|
|
|
|
|
+ assert(store::write_unlock(store::Bank::objectiveValues, 5763, 4));
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(0, mutation));
|
|
|
|
|
+ assert(!mutation.updates_account());
|
|
|
|
|
+ check_acquisition_staging(mutation, false);
|
|
|
|
|
+ assert(s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(value(store::Bank::objectiveValues, 5762) == previous);
|
|
|
|
|
+ assert(value(store::Bank::objectiveValues, 5763) == 4);
|
|
|
|
|
+ }
|
|
|
|
|
+ reset();
|
|
|
|
|
+ auto otherCharacter = baseline;
|
|
|
|
|
+ otherCharacter.characters[0].selected = false;
|
|
|
|
|
+ otherCharacter.characters[1].selected = true;
|
|
|
|
|
+ assert(store::write_account(otherCharacter));
|
|
|
|
|
+ assert(store::write_unlock(store::Bank::objectiveValues, 5762, 200));
|
|
|
|
|
+ assert(s::prepare_item_acquisition(
|
|
|
|
|
+ s::build_data::collectibles::kNoCollectibleIndex, definitions[0].definitionHash, mutation));
|
|
|
|
|
+ assert(s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(value(store::Bank::objectiveValues, 5762) == 200);
|
|
|
|
|
+ assert(store::account().characters[1].inventory.count == 1);
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(1, mutation));
|
|
|
|
|
+ assert(!mutation.updates_account());
|
|
|
|
|
+ check_acquisition_staging(mutation, false);
|
|
|
|
|
+ assert(s::preview_item_acquisition(mutation, after, unlocks));
|
|
|
|
|
+ assert(unlocks.characterObjectValues[442] == characterQuest.value);
|
|
|
|
|
+ check_encoded(false, 442, characterQuest.value);
|
|
|
|
|
+ assert(value(store::Bank::characterObjectValues, 442) == 0);
|
|
|
|
|
+ assert(s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(value(store::Bank::characterObjectValues, 442) == characterQuest.value);
|
|
|
|
|
+ assert(store::read_unlocks(unlocks, 1));
|
|
|
|
|
+ assert(unlocks.characterObjectValues[442] == 0);
|
|
|
|
|
+ for (auto previous : {characterQuest.value, 790208398, -1}) {
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(store::write_unlock(store::Bank::characterObjectValues, 442, previous));
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(1, mutation));
|
|
|
|
|
+ assert(s::preview_item_acquisition(mutation, after, unlocks));
|
|
|
|
|
+ check_encoded(false, 442, previous);
|
|
|
|
|
+ assert(s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(value(store::Bank::characterObjectValues, 442) == previous);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(0, mutation));
|
|
|
|
|
+ assert(store::write_unlock(store::Bank::objectiveValues, 5762, 200));
|
|
|
|
|
+ assert(!s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(store::account().characters[0].inventory.count == 0);
|
|
|
|
|
+ assert(value(store::Bank::objectiveValues, 5762) == 200);
|
|
|
|
|
+
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(1, mutation));
|
|
|
|
|
+ auto changed = baseline;
|
|
|
|
|
+ changed.characters[0].selected = false;
|
|
|
|
|
+ changed.characters[1].selected = true;
|
|
|
|
|
+ assert(store::write_account(changed));
|
|
|
|
|
+ assert(!s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(value(store::Bank::characterObjectValues, 442) == 0);
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(0, mutation));
|
|
|
|
|
+ mutation.questInitialization.row = 5763; // A changed contract cannot redirect the write.
|
|
|
|
|
+ assert(!s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(store::account().characters[0].inventory.count == 0);
|
|
|
|
|
+
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(0, mutation));
|
|
|
|
|
+ assert(store::execute("CREATE TEMP TRIGGER fail_item BEFORE INSERT ON items "
|
|
|
|
|
+ "BEGIN SELECT RAISE(ABORT,'injected inventory failure'); END;"));
|
|
|
|
|
+ assert(!s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(store::execute("DROP TRIGGER fail_item"));
|
|
|
|
|
+ assert(store::account().characters[0].inventory.count == 0);
|
|
|
|
|
+ assert(value(store::Bank::objectiveValues, 5762) == 0);
|
|
|
|
|
+
|
|
|
|
|
+ reset();
|
|
|
|
|
+ auto full = baseline;
|
|
|
|
|
+ auto& inventory = full.characters[0].inventory;
|
|
|
|
|
+ for (std::size_t i = 0; i < inventory.values.size(); ++i) {
|
|
|
|
|
+ auto& item = inventory.values[i];
|
|
|
|
|
+ item.instanceSoid = 0x4000000000000001ULL + i;
|
|
|
|
|
+ item.definitionHash = definitions[2].definitionHash;
|
|
|
|
|
+ item.quantity = 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ inventory.count = inventory.values.size();
|
|
|
|
|
+ assert(store::write_account(full));
|
|
|
|
|
+ assert(!s::prepare_item_acquisition_for_item(0, mutation));
|
|
|
|
|
+ assert(!mutation.prepared && value(store::Bank::objectiveValues, 5762) == 0);
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(!s::prepare_item_acquisition_for_item(65535, mutation));
|
|
|
|
|
+ assert(!mutation.prepared && value(store::Bank::objectiveValues, 5762) == 0);
|
|
|
|
|
+
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(0, mutation));
|
|
|
|
|
+ assert(store::execute("CREATE TEMP TRIGGER fail_quest BEFORE INSERT ON unlocks "
|
|
|
|
|
+ "BEGIN SELECT RAISE(ABORT,'injected quest write failure'); END;"));
|
|
|
|
|
+ assert(!s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(store::execute("DROP TRIGGER fail_quest"));
|
|
|
|
|
+ assert(store::account().characters[0].inventory.count == 0);
|
|
|
|
|
+ assert(value(store::Bank::objectiveValues, 5762) == 0);
|
|
|
|
|
+
|
|
|
|
|
+ reset();
|
|
|
|
|
+ assert(s::prepare_item_acquisition_for_item(2, mutation));
|
|
|
|
|
+ assert(mutation.questInitialization == Quest{});
|
|
|
|
|
+ check_acquisition_staging(mutation, false);
|
|
|
|
|
+ auto profileMutation = mutation;
|
|
|
|
|
+ profileMutation.profileChanged = true;
|
|
|
|
|
+ check_acquisition_staging(profileMutation, true); // Existing profile-charge behavior.
|
|
|
|
|
+ assert(s::commit_item_acquisition(mutation));
|
|
|
|
|
+ assert(value(store::Bank::objectiveValues, 5762) == 0);
|
|
|
|
|
+ assert(value(store::Bank::characterObjectValues, 442) == 0);
|
|
|
|
|
+ store::shutdown();
|
|
|
|
|
+ std::cout << "PASS production queuez staging: fresh account/character quests, existing "
|
|
|
|
|
+ "progress, ordinary item, profile charge\n";
|
|
|
|
|
+ std::cout << "PASS production acquisition + SQLite: atomic rollback, stale value/character, "
|
|
|
|
|
+ "both scopes, preservation, encoded after-image, full/refused grants, unsupported "
|
|
|
|
|
+ "control\n";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+int main(int argc, char** argv) {
|
|
|
|
|
+ std::cout << std::unitbuf;
|
|
|
|
|
+ assert(argc == 2 || argc == 3);
|
|
|
|
|
+ const auto characterQuest = parser_checks();
|
|
|
|
|
+ cache_checks();
|
|
|
|
|
+ world_reward_caller_check(argv[1]);
|
|
|
|
|
+ runtime_checks(argv[1], characterQuest);
|
|
|
|
|
+ if (argc == 3) {
|
|
|
|
|
+ content_checks(argv[2]);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|