From 21d9b0d993141beb0843e1e0da52481c4254f451 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 17 Oct 2026 00:29:27 -0500 Subject: [PATCH] Profile: cosmetic ownership is a rule, not a list of every item ever seen With "unlock all cosmetics" (and objects) on, the default, ReSkate wrote every installed item into the profile as owned and never removed one, so every mod a player ever tried stayed in the save: 8765 cosmetics and 520 objects in one profile, past the limit that left a player with no items. What is owned is now worked out from what is installed (installed_item_owned): a paid store copy never, every other item while the kind's unlock option is on, and with it off what the profile saved. The catalog given to the game, the owned list, saved outfits, the player card, the Build object browser and the park editor all ask it. Nothing per item is saved any more, and a profile with an option on drops the rows earlier versions saved, once, on the next start. profile_migration_tests checks that drop on a copy of a real save: the rows stay with the options off, are gone after a restart with them on, and nothing else in the profile changes. --- .../Customization/local_cosmetic_catalog.cpp | 18 +---- .../local_customization_runtime.cpp | 48 ++++++------ .../local_customization_runtime.h | 5 ++ .../local_player_card_runtime.cpp | 11 ++- .../Test/park_editor_transaction_tests.cpp | 4 + .../ParkEditor/park_editor_runtime.cpp | 3 +- Extension/Objects/local_object_runtime.cpp | 3 +- .../Profile/Test/profile_migration_tests.cpp | 75 ++++++++++++++++--- Extension/Profile/local_profile.h | 8 +- Extension/Profile/local_profile_store.cpp | 66 +++++----------- Extension/Profile/profile_update.cpp | 11 +++ 11 files changed, 142 insertions(+), 110 deletions(-) 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");