diff --git a/Extension/Customization/local_cosmetic_catalog.cpp b/Extension/Customization/local_cosmetic_catalog.cpp index dd93c0a9..38e1f30a 100644 --- a/Extension/Customization/local_cosmetic_catalog.cpp +++ b/Extension/Customization/local_cosmetic_catalog.cpp @@ -161,10 +161,8 @@ CosmeticShared make_cosmetic_catalog(const profile::Snapshot& snapshot) { // EASTL's iteration scans past empty buckets until this non-null sentinel. owner->buckets.back() = &owner->sentinel; owner->map = {owner->buckets.data(), bucket_count, static_cast(items.size())}; - const auto inventory = snapshot.customization.value("inventory", dingosdk::Json::object()); const auto catalog = snapshot.customization.value("catalog", dingosdk::Json::object()); const auto object_dropper = snapshot.extensions.value("object_dropper", dingosdk::Json::object()); - const auto object_inventory = object_dropper.value("inventory", dingosdk::Json::object()); const auto object_catalog = object_dropper.value("catalog", dingosdk::Json::object()); const auto groups = objects::object_groups(content_cache::catalogs()); std::set hashes; @@ -184,10 +182,8 @@ CosmeticShared make_cosmetic_catalog(const profile::Snapshot& snapshot) { } auto& node = owner->nodes[index]; node.hash = info.hash; - const auto& item_inventory = info.build_kit ? object_inventory : inventory; const auto& item_catalog = info.build_kit ? object_catalog : catalog; - const auto saved = item_inventory.find(key); - const bool owned = !cosmetic_runtime().ownership_unavailable && saved != item_inventory.end() && saved->get(); + const bool owned = installed_item_owned(snapshot, key, info.hash, info.build_kit); auto metadata = profile::item_display_metadata(key, item_catalog.value(key, dingosdk::Json::object())); if (info.build_kit) { // The same tile and category the browser layout gives this object. @@ -267,16 +263,8 @@ void publish_cosmetic_catalog() { std::vector cosmetic_owned_ids(const profile::Snapshot& snapshot) { std::vector ids; - if (cosmetic_runtime().ownership_unavailable) return ids; - const auto inventory = snapshot.customization.value("inventory", dingosdk::Json::object()); - const auto object_dropper = snapshot.extensions.value("object_dropper", dingosdk::Json::object()); - const auto object_inventory = object_dropper.value("inventory", dingosdk::Json::object()); - for (const auto& [key, info] : cosmetic_runtime().items) { - const auto& item_inventory = info.build_kit ? object_inventory : inventory; - const auto saved = item_inventory.find(key); - if (saved != item_inventory.end() && saved->is_boolean() && saved->get()) - ids.push_back(info.hash); - } + for (const auto& [key, info] : cosmetic_runtime().items) + if (installed_item_owned(snapshot, key, info.hash, info.build_kit)) ids.push_back(info.hash); return ids; } diff --git a/Extension/Customization/local_customization_runtime.cpp b/Extension/Customization/local_customization_runtime.cpp index 9f24edf1..96c96fb5 100644 --- a/Extension/Customization/local_customization_runtime.cpp +++ b/Extension/Customization/local_customization_runtime.cpp @@ -81,6 +81,22 @@ bool reserved_cosmetic_hash(std::uint32_t hash) { std::lock_guard lock(reserved_hash_mutex); return hash && reserved_hashes.contains(hash); } +bool installed_item_owned(const profile::Snapshot& snapshot, std::string_view key, std::uint32_t hash, bool build_kit) { + if (cosmetic_runtime().ownership_unavailable || reserved_cosmetic_hash(hash)) return false; + const auto option = snapshot.bool_options.find(build_kit ? profile::unlock_objects_option : profile::unlock_cosmetics_option); + if (option != snapshot.bool_options.end() && option->second) return true; + const dingosdk::Json* section = &snapshot.customization; + if (build_kit) { + const auto objects = snapshot.extensions.find("object_dropper"); + if (objects == snapshot.extensions.end() || !objects->is_object()) return false; + section = &*objects; + } + const auto inventory = section->find("inventory"); + if (inventory == section->end() || !inventory->is_object()) return false; + const auto saved = inventory->find(key); + return saved != inventory->end() && saved->is_boolean() && saved->get(); +} + bool refresh_cosmetic_catalog() { auto& s = local_runtime(); auto& c = cosmetic_runtime(); std::uintptr_t manager{}, vtable{}, buckets{}; @@ -135,30 +151,24 @@ bool refresh_cosmetic_catalog() { !read(manager + 0x3c, current_count) || current_count != count) return false; const auto& catalogs = content_cache::catalogs(); c.ownership_unavailable = !catalogs.available; - std::vector keys, objects, reserved_keys, reserved_objects; - for (const auto& [key, info] : items) { - std::string folded = key; - for (auto& letter : folded) if (letter >= 'A' && letter <= 'Z') letter = static_cast(letter + ('a' - 'A')); - const bool held = catalogs.reserved(folded); - (info.build_kit ? (held ? reserved_objects : objects) : (held ? reserved_keys : keys)).push_back(key); - } - if (!c.ownership_unavailable) { - s.store->seed_cosmetic_inventory(keys); - s.store->seed_object_inventory(objects); - s.store->reconcile_inventory(reserved_keys, reserved_objects); - } + // What is owned is worked out from the items installed (installed_item_owned), not saved item + // by item: the rows earlier versions saved go, once. + s.store->forget_unlocked_inventory(); + std::size_t objects{}; { std::set hashes; - for (const auto& [key, info] : items) + for (const auto& [key, info] : items) { + objects += info.build_kit; if (reserved_cosmetic(key)) hashes.insert(info.hash); + } std::lock_guard lock(reserved_hash_mutex); reserved_hashes = std::move(hashes); } c.items = std::move(items); c.item_count = count; c.item_manager = manager; ++c.items_generation; std::ostringstream event; - event << "{\"event\":\"local_cosmetic_catalog\",\"installed\":" << keys.size() - << ",\"objects\":" << objects.size() << '}'; + event << "{\"event\":\"local_cosmetic_catalog\",\"installed\":" << items.size() - objects + << ",\"objects\":" << objects << '}'; dingosdk::logging::event(dingosdk::logging::Channel::customization, event.str().c_str()); return true; } @@ -344,12 +354,6 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) { std::vector> item_arrays; words.reserve(16 * 257); item_arrays.reserve(16); std::vector held; - // (Looked up once: Json::value returns a copy, and this was asked for every slot, a copy - // of the whole inventory each time, in the game's own load call.) - static const auto no_inventory = dingosdk::Json::object(); - const auto inventory_found = snapshot.customization.find("inventory"); - const auto& inventory = inventory_found != snapshot.customization.end() && inventory_found->is_object() - ? *inventory_found : no_inventory; for (std::size_t i = 0; i < saved->recipes.size(); ++i) { const auto& recipe = saved->recipes[i]; const auto& expected = defaults.recipes[i]; if (recipe.template_key != expected.template_key || recipe.template_version != expected.template_version || @@ -395,7 +399,7 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) { held.push_back({i, *slot, expected.items[j].asset}); slot = &expected.items[j]; } else { - if (inventory.contains(slot->asset) && !inventory.at(slot->asset).get()) + if (!installed_item_owned(snapshot, slot->asset, found->second.hash, found->second.build_kit)) slot = &expected.items[j]; } } diff --git a/Extension/Customization/local_customization_runtime.h b/Extension/Customization/local_customization_runtime.h index a2bb9e23..f7963352 100644 --- a/Extension/Customization/local_customization_runtime.h +++ b/Extension/Customization/local_customization_runtime.h @@ -58,6 +58,11 @@ bool cosmetic_words(std::uintptr_t p, std::size_t limit, std::vectorsecond.hash, it->second.build_kit)) + return false; // Each card category is also its recipe slot hash in the installed template. return std::find(it->second.categories.begin(), it->second.categories.end(), slot.slot) != it->second.categories.end(); @@ -96,9 +97,8 @@ bool player_card_item_owned(const profile::CosmeticSlot& slot, const dingosdk::J bool player_card_owned(const profile::CosmeticLoadout& value) { const auto snapshot_shared = local_runtime().store->shared_snapshot(); const auto& snapshot = *snapshot_shared; - const auto& inventory = snapshot.customization.at("inventory"); for (const auto& slot : value.recipes.front().items) - if (!player_card_item_owned(slot, inventory)) return false; + if (!player_card_item_owned(slot, snapshot)) return false; return true; } @@ -264,10 +264,9 @@ void update_player_card() { // slot. The saved card keeps it until the player changes the card. const auto snapshot_shared = s.store->shared_snapshot(); const auto& snapshot = *snapshot_shared; - const auto& inventory = snapshot.customization.at("inventory"); const auto& defaults = current.recipes.front().items; for (auto& slot : restore->recipes.front().items) { - if (player_card_item_owned(slot, inventory)) continue; + if (player_card_item_owned(slot, snapshot)) continue; const auto fallback = std::find_if(defaults.begin(), defaults.end(), [&](const profile::CosmeticSlot& item) { return item.slot == slot.slot; }); if (fallback == defaults.end()) { restore.reset(); break; } diff --git a/Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp b/Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp index 3fd9389f..b936f372 100644 --- a/Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp +++ b/Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp @@ -219,6 +219,10 @@ CosmeticRuntime &cosmetic_runtime() { static CosmeticRuntime state; return state; } +// (No profile here grants an object: nothing is owned, as with the empty inventory this stood in for.) +bool installed_item_owned(const profile::Snapshot &, std::string_view, std::uint32_t, bool) { + return false; +} bool placement_session_ready() { return true; } diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.cpp b/Extension/Objects/ParkEditor/park_editor_runtime.cpp index d204cc0d..cd78af8c 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.cpp +++ b/Extension/Objects/ParkEditor/park_editor_runtime.cpp @@ -166,13 +166,12 @@ void refresh_catalog() { if (e.assets && e.catalog_revision == snapshot.revision && e.catalog_manager == c.item_manager) return; const auto config = snapshot.extensions.value("object_dropper", Json::object()); - const auto inventory = config.value("inventory", Json::object()); const auto catalog = config.value("catalog", Json::object()); const auto &cache = content_cache::catalogs(); const auto groups = objects::object_groups(cache); auto assets = std::make_shared>(); for (const auto &[key, item] : c.items) { - if (!item.build_kit || !inventory.value(key, false) || assets->size() >= 4096) + if (!item.build_kit || !profile_runtime::installed_item_owned(snapshot, key, item.hash, true) || assets->size() >= 4096) continue; std::string title = key.starts_with("own_") ? key.substr(4) : key; std::replace(title.begin(), title.end(), '_', ' '); diff --git a/Extension/Objects/local_object_runtime.cpp b/Extension/Objects/local_object_runtime.cpp index 1d39cfea..a8297b81 100644 --- a/Extension/Objects/local_object_runtime.cpp +++ b/Extension/Objects/local_object_runtime.cpp @@ -59,11 +59,10 @@ std::string object_title(const std::string& key, const dingosdk::Json& overrides void fill_object_categories(std::uintptr_t result, const profile::Snapshot& snapshot) { const auto section = snapshot.extensions.value("object_dropper", dingosdk::Json::object()); - const auto inventory = section.value("inventory", dingosdk::Json::object()); const auto catalog = section.value("catalog", dingosdk::Json::object()); std::vector owned; for (const auto& [key, info] : cosmetic_runtime().items) - if (info.build_kit && inventory.value(key, false)) + if (info.build_kit && installed_item_owned(snapshot, key, info.hash, true)) owned.push_back({key, info.category, object_title(key, catalog.value(key, dingosdk::Json::object()))}); // Titles come from the cache's category records. Their icons are cdn:/ links // that cannot load offline, and the browser would draw the link as text. diff --git a/Extension/Profile/Test/profile_migration_tests.cpp b/Extension/Profile/Test/profile_migration_tests.cpp index f818e8b5..572688e5 100644 --- a/Extension/Profile/Test/profile_migration_tests.cpp +++ b/Extension/Profile/Test/profile_migration_tests.cpp @@ -5,6 +5,13 @@ #include "Extension/Profile/local_profile.h" #include +namespace { +std::size_t saved_items(const dingosdk::profile::Snapshot& snapshot, bool objects) { + const auto& section = objects ? snapshot.extensions.value("object_dropper", dingosdk::Json::object()) : snapshot.customization; + return section.value("inventory", dingosdk::Json::object()).size(); +} +} + int main(int argc, char** argv) { using namespace dingosdk; if (argc < 2) { @@ -12,15 +19,61 @@ int main(int argc, char** argv) { return 0; } const auto save = std::filesystem::absolute(argv[1]) / "reskate.sqlite3"; - profile::Store store(save); - const auto snapshot = store.snapshot(); - const auto policy = profile::challenge_policy(snapshot); - const auto& section = snapshot.extensions.at("challenges"); - std::size_t available{}; - for (const auto& [id, definition] : policy.catalog) available += definition.available; - std::cout << "challenges: " << policy.catalog.size() << " in catalogue (" << available << " available), " - << section.at("catalog").size() << " stored changes, " << section.at("progress").size() - << " with progress\n" - << "entitlements: " << snapshot.entitlements.size() << "\n"; - return policy.catalog.empty() ? 1 : 0; + int failures = 0; + const auto check = [&](bool condition, const char* message) { + if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; } + }; + { + profile::Store store(save); + const auto snapshot = store.snapshot(); + const auto policy = profile::challenge_policy(snapshot); + const auto& section = snapshot.extensions.at("challenges"); + std::size_t available{}; + for (const auto& [id, definition] : policy.catalog) available += definition.available; + std::cout << "challenges: " << policy.catalog.size() << " in catalogue (" << available << " available), " + << section.at("catalog").size() << " stored changes, " << section.at("progress").size() + << " with progress\n" + << "entitlements: " << snapshot.entitlements.size() << "\n"; + check(!policy.catalog.empty(), "the challenge catalogue loads"); + + // Ownership is a rule while a kind's unlock option is on: the rows earlier versions saved + // for every item they saw go, and nothing else in the profile changes. Off, they stay. + const auto cosmetics = store.bool_option(profile::unlock_cosmetics_option); + const auto objects = store.bool_option(profile::unlock_objects_option); + std::cout << "saved ownership: " << saved_items(snapshot, false) << " cosmetic(s), " << saved_items(snapshot, true) + << " object(s)\n"; + store.set_bool_option(profile::unlock_cosmetics_option, false); + store.set_bool_option(profile::unlock_objects_option, false); + store.forget_unlocked_inventory(); + check(saved_items(store.snapshot(), false) == saved_items(snapshot, false) && + saved_items(store.snapshot(), true) == saved_items(snapshot, true), + "with the unlock options off, saved ownership stays"); + store.set_bool_option(profile::unlock_cosmetics_option, true); + store.set_bool_option(profile::unlock_objects_option, true); + store.forget_unlocked_inventory(); + // (The options as they were, so only the inventory differs from the save that was copied.) + if (cosmetics) store.set_bool_option(profile::unlock_cosmetics_option, *cosmetics); + if (objects) store.set_bool_option(profile::unlock_objects_option, *objects); + } + { + profile::Store reopened(save); + const auto after = reopened.snapshot(); + check(saved_items(after, false) == 0 && saved_items(after, true) == 0, "with the unlock options on, saved ownership is gone after a restart"); + profile::Store original(std::filesystem::absolute(argv[1]) / "original" / "reskate.sqlite3"); + const auto before = original.snapshot(); + auto expected = before, actual = after; + expected.customization.erase("inventory"); + actual.customization.erase("inventory"); + if (expected.extensions.contains("object_dropper")) expected.extensions.at("object_dropper").erase("inventory"); + if (actual.extensions.contains("object_dropper")) actual.extensions.at("object_dropper").erase("inventory"); + expected.revision = actual.revision = 0; + check(expected.customization == actual.customization, "outfits, the player card and the rest of customization are unchanged"); + check(expected.extensions == actual.extensions, "every other saved section is unchanged"); + check(expected.bool_options == actual.bool_options && expected.entitlements == actual.entitlements && + expected.quests == actual.quests && expected.settings == actual.settings, + "options, entitlements, quests and settings are unchanged"); + } + if (failures) return 1; + std::cout << "profile migration: ok\n"; + return 0; } diff --git a/Extension/Profile/local_profile.h b/Extension/Profile/local_profile.h index 23800326..ea9516d3 100644 --- a/Extension/Profile/local_profile.h +++ b/Extension/Profile/local_profile.h @@ -178,9 +178,11 @@ public: void save_cosmetic_loadout(std::string_view, const CosmeticLoadout&); std::optional player_card() const; void save_player_card(const CosmeticLoadout&); - void seed_cosmetic_inventory(const std::vector&); - void seed_object_inventory(const std::vector&); - void reconcile_inventory(const std::vector& cosmetics, const std::vector& objects); + // With a kind's unlock option on, what the player owns is a rule, not a list + // (profile_runtime::installed_item_owned): every installed item but a paid store copy. The + // ownership rows earlier versions saved for every item they saw (every mod's, removed mods' + // too) are dropped here, once, for each kind whose option is on. + void forget_unlocked_inventory(); std::uint32_t selected_cosmetic_preset() const; void set_selected_cosmetic_preset(std::uint32_t); void save_park_choice(unsigned lot, std::string_view id); diff --git a/Extension/Profile/local_profile_store.cpp b/Extension/Profile/local_profile_store.cpp index 4df82de5..7132848f 100644 --- a/Extension/Profile/local_profile_store.cpp +++ b/Extension/Profile/local_profile_store.cpp @@ -42,59 +42,27 @@ void Store::save_player_card(const CosmeticLoadout& value) { update.commit(); } -void Store::seed_cosmetic_inventory(const std::vector& keys) { +void Store::forget_unlocked_inventory() { std::lock_guard lock(mutex_); - const auto enabled = value_.bool_options.find(unlock_cosmetics_option); - if (enabled == value_.bool_options.end() || !enabled->second) return; - Update update(*this); - update.json(value_.customization, {"catalog"}, true); - const auto& inventory = update.json(value_.customization, {"inventory"}, true); - for (const auto& key : keys) { - require(valid_text(key), "Invalid installed cosmetic key"); - const auto found = inventory.find(key); - if (found == inventory.end() || !found->is_boolean() || !found->get()) - update.json(value_.customization, {"inventory", key}) = true; - } - update.commit(); - -} -void Store::seed_object_inventory(const std::vector& keys) { - std::lock_guard lock(mutex_); - const auto enabled = value_.bool_options.find(unlock_objects_option); - if (enabled == value_.bool_options.end() || !enabled->second) return; - Update update(*this); - update.json(value_.extensions, {"object_dropper", "catalog"}, true); - const auto& inventory = update.json(value_.extensions, {"object_dropper", "inventory"}, true); - for (const auto& key : keys) { - require(valid_text(key), "Invalid installed object key"); - const auto found = inventory.find(key); - if (found == inventory.end() || !found->is_boolean() || !found->get()) - update.json(value_.extensions, {"object_dropper", "inventory", key}) = true; - } - update.commit(); - -} -void Store::reconcile_inventory(const std::vector& cosmetics, const std::vector& objects) { - std::lock_guard lock(mutex_); - const auto cosmetic_inventory = value_.customization.value("inventory", dingosdk::Json::object()); - const auto object_inventory = - value_.extensions.value("object_dropper", dingosdk::Json::object()).value("inventory", dingosdk::Json::object()); - const auto owned = [](const dingosdk::Json& inventory, const std::string& key) { - const auto found = inventory.find(key); - return found != inventory.end() && found->is_boolean() && found->get(); + const auto unlocked = [&](std::string_view option) { + const auto found = value_.bool_options.find(option); + return found != value_.bool_options.end() && found->second; + }; + const auto saved = [](const dingosdk::Json& section) { + const auto found = section.find("inventory"); + return found != section.end() && found->is_object() && !found->empty(); }; Update update(*this); bool changed{}; - for (const auto& key : cosmetics) - if (owned(cosmetic_inventory, key)) { - update.json(value_.customization, {"inventory", key}) = false; - changed = true; - } - for (const auto& key : objects) - if (owned(object_inventory, key)) { - update.json(value_.extensions, {"object_dropper", "inventory", key}) = false; - changed = true; - } + if (unlocked(unlock_cosmetics_option) && saved(value_.customization)) { + update.json(value_.customization, {"inventory"}) = dingosdk::Json::object(); + changed = true; + } + const auto objects = value_.extensions.find("object_dropper"); + if (unlocked(unlock_objects_option) && objects != value_.extensions.end() && objects->is_object() && saved(*objects)) { + update.json(value_.extensions, {"object_dropper", "inventory"}) = dingosdk::Json::object(); + changed = true; + } if (changed) update.commit(); } std::uint32_t Store::selected_cosmetic_preset() const { diff --git a/Extension/Profile/profile_update.cpp b/Extension/Profile/profile_update.cpp index 6e7427d1..61f2f5e4 100644 --- a/Extension/Profile/profile_update.cpp +++ b/Extension/Profile/profile_update.cpp @@ -28,6 +28,7 @@ void Store::Update::encode(Json& patch) { } std::set sections, loadouts, challenge_rows, challenge_catalog; std::map> inventory; + std::set replaced_inventory; // a kind's whole inventory set at once bool settings{}, card{}, score{}; for (const auto& change : json_edits_) { auto path = change.path; @@ -41,6 +42,8 @@ void Store::Update::encode(Json& patch) { if (section == "customization" && path[0] == "player_card") card = true; if ((section == "customization" || section == "object_dropper") && path[0] == "inventory" && path.size() > 1) inventory[section == "customization" ? "cosmetic" : "object"].insert(path[1]); + if ((section == "customization" || section == "object_dropper") && path[0] == "inventory" && path.size() == 1) + replaced_inventory.insert(section == "customization" ? "cosmetic" : "object"); if (section == "progress" && path[0] == "rip_score") score = true; if (section == "challenges" && path.size() > 1) { if (path[0] == "progress") challenge_rows.insert(path[1]); @@ -88,6 +91,14 @@ void Store::Update::encode(Json& patch) { flatten(flatten, residual, "/settings"); add("profile_extensions", Json::array({"settings", residual.dump()})); } + for (const auto& kind : replaced_inventory) { + // Every row of the kind goes, and the rows of what it holds now come back. + remove("inventory", [&](const Json& row) { return row[0] == kind; }); + const auto& rows = kind == "cosmetic" ? value.customization.at("inventory") : + value.extensions.at("object_dropper").at("inventory"); + detail::require(rows.is_object() && rows.size() <= saved_item_limit, "Inventory exceeds limit"); + for (const auto& [key, owned] : rows.items()) inventory[kind].insert(key); + } for (const auto& [kind, keys] : inventory) { const auto& rows = kind == "cosmetic" ? value.customization.at("inventory") : value.extensions.at("object_dropper").at("inventory");