diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 33c835d5e..be825d061 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -952,6 +952,9 @@ ships = true # Do you want to sync slots data? slots = true +# Do you want to sync faction store offer data? +store_offers = false + # Do you want to sync owned forbidden tech data? tech = true diff --git a/mods/src/config.h b/mods/src/config.h index cf19f9982..5d5f74b80 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -32,6 +32,7 @@ class SyncConfig Resources, Ships, Slots, + StoreOffers, Tech, Traits }; @@ -59,6 +60,7 @@ class SyncConfig bool resources = false; bool ships = false; bool slots = false; + bool store_offers = false; bool tech = false; bool traits = false; @@ -81,6 +83,7 @@ constexpr std::array SyncOptions{ SyncConfig::Option{SyncConfig::Type::Resources, "resource", "resources", &SyncConfig::resources}, SyncConfig::Option{SyncConfig::Type::Ships, "ship", "ships", &SyncConfig::ships}, SyncConfig::Option{SyncConfig::Type::Slots, "slot", "slots", &SyncConfig::slots}, + SyncConfig::Option{SyncConfig::Type::StoreOffers, "store_offer", "store_offers", &SyncConfig::store_offers}, SyncConfig::Option{SyncConfig::Type::Tech, "ft", "tech", &SyncConfig::tech}, SyncConfig::Option{SyncConfig::Type::Traits, "trait", "traits", &SyncConfig::traits}, }; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 584987176..b3e128516 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -250,6 +250,7 @@ namespace Sync constexpr bool resources = true; constexpr bool ships = true; constexpr bool slots = true; + constexpr bool store_offers = false; constexpr bool tech = true; constexpr bool traits = true; constexpr const char* token = ""; diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index bba2c6a2c..a27dbe01c 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -3,6 +3,8 @@ #include "file.h" #include "str_utils.h" +#include +#include #include #include #include @@ -31,14 +33,18 @@ #include #include +#include #include +#include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -2227,6 +2233,115 @@ static void active_officer_traits(std::unique_ptr&& bytes) } } +// Store offers arrive in Platform responses. Prices are in Resource.amount; amountUlong is the fallback. +static int64_t store_offer_amount(const Digit::PrimePlatform::Content::Resource& resource) +{ return static_cast(resource.amount() != 0 ? resource.amount() : resource.amountulong()); } + +// Platform ids are strings; emit numeric ids as numbers like the rest of the sync data. +static nlohmann::json store_offer_id(const std::string& id) +{ + int64_t value = 0; + const auto* end = id.data() + id.size(); + if (const auto [ptr, ec] = std::from_chars(id.data(), end, value); !id.empty() && ec == std::errc{} && ptr == end) { + return value; + } + + return id; +} + +static const Digit::PrimePlatform::Content::ContentItem* +store_offer_consumable(const Digit::PrimePlatform::Content::BundleContent& content) +{ + for (const auto* list : {&content.static_(), &content.showcase(), &content.gifts()}) { + for (const auto& item : list->items()) { + if (item.type() == "consumable") { + return &item; + } + } + } + + return nullptr; +} + +// Emits one snapshot of the faction store's consumable offers (favor levels) whenever its content changes. +static void store_offers(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + + static constexpr std::string_view category = "factions"; + static size_t store_offers_state = 0; + static std::mutex store_offers_mtx; + + if (auto response = Digit::PrimePlatform::Content::Bundles(); response.ParseFromString(*bytes)) { + + http::logging::trace("PROCESS", "store offers", STR_FORMAT("Processing {} store offers", response.bundles__size())); + + // A store offers group carries whole categories; skip groups without the faction store. + struct Offer { + const Digit::PrimePlatform::Content::Bundle* bundle; + const Digit::PrimePlatform::Content::ContentItem* consumable; + }; + std::vector bundles; + bool has_category = false; + for (const auto& bundle : response.bundles_()) { + if (bundle.category() == category) { + has_category = true; + if (const auto* consumable = store_offer_consumable(bundle.content()); consumable != nullptr) { + bundles.push_back({&bundle, consumable}); + } + } + } + if (!has_category) { + return; + } + + // Sort by bundle id, then consumable id, so the snapshot (and therefore the de-dup hash) does not depend on server + // order. + std::ranges::sort(bundles, [](const Offer& a, const Offer& b) { + return std::pair{a.bundle->bundleid(), std::string_view{a.consumable->id()}} + < std::pair{b.bundle->bundleid(), std::string_view{b.consumable->id()}}; + }); + + auto offers = json::array(); + for (const auto& [bundle, consumable] : bundles) { + auto cost = json::array(); + for (const auto& entry : bundle->cost()) { + for (const auto& resource : entry.cost_()) { + cost.push_back({{"rid", store_offer_id(resource.id())}, {"amount", store_offer_amount(resource)}}); + } + } + + // For faction_standing requirements the amount is a zero-based tier index. + auto req = json::array(); + for (const auto& resource : bundle->requirements()) { + req.push_back({{"type", resource.type()}, + {"id", store_offer_id(resource.id())}, + {"amount", store_offer_amount(resource)}}); + } + + const auto& details = bundle->offerdetails(); + offers.push_back({{"bid", bundle->bundleid()}, + {"cid", store_offer_id(consumable->id())}, + {"cost", cost}, + {"req", req}, + {"valid_count", details.validcount()}, + {"same_remaining_count", details.sameremainingcount()}}); + } + + json event = {{"type", SyncConfig::Type::StoreOffers}, {"category", category}, {"offers", offers}}; + + // Queue under the lock so the remembered snapshot and the queue order agree. + const auto state_value = std::hash{}(event); + std::scoped_lock lk(store_offers_mtx); + if (store_offers_state != state_value) { + store_offers_state = state_value; + workers::queue_data(SyncConfig::Type::StoreOffers, json::array({event})); + } + } else { + spdlog::error("Failed to parse store offers"); + } +} + namespace json { static void battle_result_headers(const nlohmann::json& section) @@ -2697,6 +2812,89 @@ static void HandleEntityGroup(EntityGroup* entity_group) } } +// Platform responses (store offers among them) reach the same ProcessResultInternal detour as game server responses: +// both model registries share one compiled method. Their entity group types use the Platform enum, so they must not +// go through HandleEntityGroup. +static bool IsPlatformResponse(ServiceResponse* service_response) +{ + static const Il2CppClass* platform_class = nullptr; + + const auto* const response = reinterpret_cast(service_response); + if (response == nullptr || response->klass == nullptr) { + return false; + } + if (response->klass == platform_class) { + return true; + } + + const char* const name_space = il2cpp_class_get_namespace(response->klass); + const char* const name = il2cpp_class_get_name(response->klass); + if (name_space != nullptr && name != nullptr && std::strcmp(name_space, "Digit.Platform.Models") == 0 + && std::strcmp(name, "ServiceResponse") == 0) { + platform_class = response->klass; + return true; + } + + return false; +} + +static void HandlePlatformResponse(ServiceResponse* service_response) +{ + auto* const response = reinterpret_cast(service_response); + static auto* const response_class = response->klass; + static auto entity_groups = IL2CppClassHelper{response_class}.GetProperty("EntityGroups"); + if (response->klass != response_class) { + return; + } + + auto* const groups = entity_groups.GetRaw(response); + if (groups == nullptr) { + return; + } + + for (int i = 0; i < groups->Count; ++i) { + auto* const group = reinterpret_cast(groups->get_Item(i)); + if (group == nullptr || group->klass == nullptr) { + continue; + } + + static auto* const group_class = group->klass; + static auto group_type = IL2CppClassHelper{group_class}.GetProperty("Type"); + static auto group_bytes = IL2CppClassHelper{group_class}.GetProperty("Group"); + if (group->klass != group_class) { + continue; + } + + const auto* const type = group_type.Get(group); + if (type == nullptr || *type != Digit::Platform::Models::EntityGroup::TYPE_STOREOFFERS) { + continue; + } + + auto* const bytes = group_bytes.GetRaw(group); + if (bytes == nullptr || bytes->bytes == nullptr || bytes->Length <= 0) { + continue; + } + + auto payload = std::make_unique(reinterpret_cast(bytes->bytes->m_Items), + static_cast(bytes->Length)); + try { + std::thread([p = std::move(payload)]() mutable { + try { + processors::store_offers(std::move(p)); + } catch (const std::exception& e) { + spdlog::error("Exception in HandlePlatformResponse: {}", e.what()); + } catch (...) { + spdlog::error("Unknown exception in HandlePlatformResponse"); + } + }).detach(); + } catch (const std::exception& e) { + spdlog::error("Failed to spawn async task: {}", e.what()); + } catch (...) { + spdlog::error("Failed to spawn async task: unknown exception"); + } + } +} + namespace hooks { // IL2CPP value type: keep this parameter by value so following arguments retain their platform ABI positions. @@ -2737,6 +2935,12 @@ static void* RtcParser_ParseFinalPayload(auto original, void* _this, CentrifugoI static void GameServerModelRegistry_ProcessResultInternal(auto original, void* _this, void* parsing_context, ServiceResponse* service_response, MethodInfo* method) { + // Only the store offers option looks at Platform responses; everyone else keeps the unchanged path below. + if (Config::Get().sync_options.store_offers && IsPlatformResponse(service_response)) { + HandlePlatformResponse(service_response); + return original(_this, parsing_context, service_response, method); + } + auto* const entity_groups = service_response->EntityGroups; for (int i = 0; i < entity_groups->Count; ++i) { auto* const entity_group = entity_groups->get_Item(i); diff --git a/mods/src/prime/proto/Digit.PrimePlatform.Content.proto b/mods/src/prime/proto/Digit.PrimePlatform.Content.proto index a73f19870..25f4335f0 100644 --- a/mods/src/prime/proto/Digit.PrimePlatform.Content.proto +++ b/mods/src/prime/proto/Digit.PrimePlatform.Content.proto @@ -253,7 +253,7 @@ message Cost { } message Resource { - uint32 amount = 1; + fixed32 amount = 1; uint32 amountOld = 2; string id = 3; string type = 4;