Files
ReSkate/Extension/Profile/profile_update.cpp
T
AyeZeeBB 21d9b0d993 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.
2026-10-17 00:29:27 -05:00

152 lines
9.2 KiB
C++

#include "profile_update.h"
#include <algorithm>
#include <cmath>
namespace dingosdk::profile {
void Store::Update::encode(Json& patch) {
const auto& value = store_.value_;
const auto& previous = store_.database_->document();
const auto add = [&](std::string_view table, Json row, unsigned keys = 1) {
const auto key = storage::row_key(row, keys);
patch[table][key] = std::move(row);
};
const auto remove = [&](std::string_view table, const auto& predicate) {
for (const auto& [key, row] : previous.at(table).items())
if (predicate(row)) patch[table][key] = nullptr;
};
for (const auto& [table, keys] : records_) for (const auto& key : keys) {
if (table == "player_settings") add(table, Json::array({"options", key, Json(value.bool_options.at(key)).dump()}), 2);
else if (table == "entitlements") add(table, Json::array({key, value.entitlements.at(key) ? 1 : 0}));
else if (table == "quests") add(table, Json::array({key, value.quests.at(key)}));
else if (table == "neighborhood_ranks") add(table, Json::array({key, value.neighborhood_ranks.at(key)}));
else if (table == "play_events") {
const auto& event = value.play_events.at(key);
detail::require(event.id == key && detail::valid_text(key) &&
detail::valid_text(event.context, true) && event.count >= 0, "Invalid play event");
add(table, Json::array({key, event.context, std::to_string(event.timestamp), event.count}));
} else throw std::runtime_error("Unknown profile record update");
}
std::set<std::string> sections, loadouts, challenge_rows, challenge_catalog;
std::map<std::string, std::set<std::string>> inventory;
std::set<std::string> replaced_inventory; // a kind's whole inventory set at once
bool settings{}, card{}, score{};
for (const auto& change : json_edits_) {
auto path = change.path;
if (change.root == &value.settings) { settings = true; continue; }
std::string section;
if (change.root == &value.customization) section = "customization";
else { section = path.front(); path.erase(path.begin()); }
sections.insert(section);
if (path.empty()) continue;
if (section == "customization" && path[0] == "loadouts" && path.size() > 1) loadouts.insert(path[1]);
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]);
if (path[0] == "catalog") challenge_catalog.insert(path[1]);
}
}
// Preserve the structural/unknown fields without copying any item catalog,
// inventory, loadout collection, or unrelated progression records.
for (const auto& section : sections) {
const auto& source = section == "customization" ? value.customization : value.extensions.at(section);
Json residual = Json::object();
for (const auto& [key, field] : source.items()) {
const bool empty = ((section == "customization" || section == "object_dropper") &&
(key == "inventory" || key == "catalog")) ||
(section == "customization" && key == "loadouts") ||
(section == "challenges" && (key == "catalog" || key == "progress"));
if (empty) residual[key] = Json::object();
else if (!((section == "customization" && key == "player_card") ||
(section == "progress" && key == "rip_score"))) residual[key] = field;
}
if (section == "progress") for (auto key : {"quests", "play_events", "neighborhood_ranks"}) residual[key] = Json::object();
add("profile_extensions", Json::array({section, residual.dump()}));
}
if (settings) {
detail::validate_settings(value);
(void)park_choices(value); (void)world_layer_choices(value);
(void)world_controls(value); (void)graphics_controls(value); (void)profile::freecam_controller(value); (void)profile::freecam_controller_binding(value); (void)profile::freecam_binding(value); (void)profile::tp_to_freecam_binding(value); (void)profile::noclip_binding(value);
(void)profile::forward_velocity_binding(value);
(void)profile::vote_yes_binding(value); (void)profile::vote_no_binding(value);
for (const auto& slot : action_binds) (void)profile::action_binding(value, slot.key);
(void)profile::up_velocity_binding(value);
(void)profile::offboard_up_velocity_binding(value);
remove("player_settings", [](const Json& row) { return row[0] != "options"; });
auto residual = value.settings;
const auto flatten = [&](auto&& self, Json& node, const std::string& scope) -> void {
for (auto it = node.items().begin(); it != node.items().end();) {
if (it->second.is_object()) {
self(self, it->second, scope + "/" + database::escape(it->first)); ++it;
} else {
add("player_settings", Json::array({scope, it->first, it->second.dump()}), 2);
it = node.items().erase(it);
}
}
};
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");
detail::require(rows.is_object() && rows.size() <= saved_item_limit, "Inventory exceeds limit");
for (const auto& key : keys) {
detail::require(detail::valid_text(key) && rows.at(key).is_boolean(), "Invalid inventory item");
add("inventory", Json::array({kind, key, rows.at(key).get<bool>() ? 1 : 0}), 2);
}
}
const auto outfit = [&](std::string_view table, const Json& key, Json row) {
(void)decode_loadout(row);
const auto format = row.at("recipe_format"), recipes = row.at("recipes");
row.erase("recipe_format"); row.erase("recipes");
add(table, Json::array({key, format, recipes.dump(), row.dump()}));
};
if (!loadouts.empty()) {
const auto& rows = value.customization.at("loadouts");
detail::require(rows.size() <= 10, "Invalid local preset collection");
for (const auto& id : loadouts) outfit("cosmetic_loadouts", id, rows.at(id));
}
if (card) {
validate_player_card(decode_loadout(value.customization.at("player_card")));
outfit("player_card", 1, value.customization.at("player_card"));
}
if (score) {
(void)rip_score(value);
auto row = value.extensions.at("progress").at("rip_score");
const auto total = row.at("value"), cap = row.at("cap"), level = row.at("level");
row.erase("value"); row.erase("cap"); row.erase("level");
add("rip_score", Json::array({1, total, cap, level, row.dump()}));
}
if (!challenge_rows.empty() || !challenge_catalog.empty()) detail::validate_challenges(value);
for (const auto& id : challenge_catalog)
add("catalog_entries", Json::array({"challenge", id, value.extensions.at("challenges").at("catalog").at(id).dump()}), 2);
for (const auto& id : challenge_rows) {
const auto& row = value.extensions.at("challenges").at("progress").at(id);
auto extra = row; for (auto key : {"attempt", "completed_criteria", "receipt"}) extra.erase(key);
add("challenge_progress", Json::array({id, row.at("attempt"), extra.dump()}));
remove("challenge_goals", [&](const Json& old) { return old[0] == id; });
const auto& goals = row.at("completed_criteria");
for (std::size_t i = 0; i < goals.size(); ++i) add("challenge_goals", Json::array({id, goals[i], i}), 2);
if (row.contains("receipt")) {
const auto& receipt = row.at("receipt"); auto extras = receipt;
for (auto key : {"attempt", "status", "completed_criteria", "grants"}) extras.erase(key);
add("challenge_receipts", Json::array({id, receipt.at("attempt"), receipt.at("status"),
receipt.at("completed_criteria").dump(), receipt.at("grants").dump(), extras.dump()}));
}
}
}
}