diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index cb8252684..ba29585aa 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -99,6 +99,11 @@ + + + + + @@ -205,6 +210,8 @@ + + @@ -720,9 +727,12 @@ + - + + + @@ -1339,6 +1349,11 @@ + + + + + @@ -1626,7 +1641,10 @@ - + + + + diff --git a/Sunrise/resources/database/investment_schema.sql b/Sunrise/resources/database/investment_schema.sql index 662d673fd..a96d6c495 100644 --- a/Sunrise/resources/database/investment_schema.sql +++ b/Sunrise/resources/database/investment_schema.sql @@ -1,5 +1,5 @@ PRAGMA application_id = 1397902921; -PRAGMA user_version = 2; +PRAGMA user_version = 3; CREATE TABLE account ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -99,6 +99,13 @@ CREATE TABLE family5 ( UNIQUE (kind, slot) ) STRICT; +CREATE TABLE character_objective_values ( + character_soid INTEGER NOT NULL REFERENCES characters(soid) DEFERRABLE INITIALLY DEFERRED, + slot INTEGER NOT NULL CHECK (slot BETWEEN 0 AND 32767), + value INTEGER NOT NULL CHECK (value BETWEEN 0 AND 2147483647), + PRIMARY KEY (character_soid, slot) +) STRICT; + CREATE TABLE entitlements ( position INTEGER PRIMARY KEY, name TEXT NOT NULL, diff --git a/Sunrise/resources/database/vendor_reputation_content.sql b/Sunrise/resources/database/vendor_reputation_content.sql new file mode 100644 index 000000000..f344f3d70 --- /dev/null +++ b/Sunrise/resources/database/vendor_reputation_content.sql @@ -0,0 +1,68 @@ +-- Build 86657.20.08.23.1800-9: authored Vendor itemList and Faction tokenValues. +-- Faction/Progression component: 83e9ee00-d5d1-453c-b454-ea08c1acefb4. +-- Decompressed Vendor SHA256: 649c78fc84b97070c7e859d6d587f162d7264a21f403d319de5a087d6ccc05c1. +-- Decompressed Faction SHA256: 8f6a3ec405b8c891935764e4a3c27a202029207f9d951c2054e6ae135bf2288e. +-- Static build data is memory-only, not player state; vendor sales and faction links are rechecked. + +-- Sale/category indexes are vendor-local. Hash columns name manifest definitions. +-- Each transaction awards cost_quantity * xp_per_unit; the rate is per charged item. +CREATE TABLE reputation_sales ( + vendor_hash INTEGER NOT NULL, + sale_index INTEGER NOT NULL, + faction_hash INTEGER NOT NULL, + placeholder_hash INTEGER NOT NULL, + cost_hash INTEGER NOT NULL, + category_index INTEGER NOT NULL, + cost_quantity INTEGER NOT NULL, + xp_per_unit INTEGER NOT NULL, + PRIMARY KEY (vendor_hash, sale_index) +) WITHOUT ROWID; + +-- Zavala (69482069), Vanguard Tactical faction (611314723), turn-in category 2. +-- Vanguard Tactician Rewards placeholder (3987308529), Vanguard Tactician Token (3899548068). +INSERT INTO reputation_sales + (vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash, + category_index, cost_quantity, xp_per_unit) VALUES + (69482069, 2, 611314723, 3987308529, 3899548068, 2, 1, 100), + (69482069, 3, 611314723, 3987308529, 3899548068, 2, 5, 100), + (69482069, 4, 611314723, 3987308529, 3899548068, 2, 10, 100); + +-- Devrim Kay (396892126), EDZ faction (4235119312), turn-in category 4. +-- EDZ Rewards placeholder (61430328), EDZ Token (2640973641). +INSERT INTO reputation_sales + (vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash, + category_index, cost_quantity, xp_per_unit) VALUES + (396892126, 32, 4235119312, 61430328, 2640973641, 4, 1, 100), + (396892126, 33, 4235119312, 61430328, 2640973641, 4, 5, 100), + (396892126, 34, 4235119312, 61430328, 2640973641, 4, 10, 100), + -- Destination-material placeholder (1317670974), Dusklight Shard (950899352). + (396892126, 35, 4235119312, 1317670974, 950899352, 4, 1, 50), + (396892126, 36, 4235119312, 1317670974, 950899352, 4, 5, 50), + (396892126, 37, 4235119312, 1317670974, 950899352, 4, 10, 50), + -- The same placeholder accepts Dusklight Crystal (478751073) at a separate XP rate. + (396892126, 38, 4235119312, 1317670974, 478751073, 4, 1, 250), + (396892126, 39, 4235119312, 1317670974, 478751073, 4, 5, 250), + (396892126, 40, 4235119312, 1317670974, 478751073, 4, 10, 250); + +-- Banshee-44 (672118013), Gunsmith faction (1021210278), turn-in category 7. +-- Gunsmith Rewards placeholder (3831705402), Gunsmith Materials (685157383). +INSERT INTO reputation_sales + (vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash, + category_index, cost_quantity, xp_per_unit) VALUES + (672118013, 2, 1021210278, 3831705402, 685157383, 7, 1, 30), + (672118013, 3, 1021210278, 3831705402, 685157383, 7, 5, 30), + (672118013, 4, 1021210278, 3831705402, 685157383, 7, 10, 30), + (672118013, 5, 1021210278, 3831705402, 685157383, 7, 25, 30), + -- The same placeholder accepts Weapon Telemetry (685157381) at a separate XP rate. + (672118013, 6, 1021210278, 3831705402, 685157381, 7, 1, 25), + (672118013, 7, 1021210278, 3831705402, 685157381, 7, 5, 25), + (672118013, 8, 1021210278, 3831705402, 685157381, 7, 10, 25); + +-- Shaxx (3603221665), Crucible faction (697030790), turn-in category 8. +-- Crucible Rewards placeholder (265113466), Crucible Token (183980811). +INSERT INTO reputation_sales + (vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash, + category_index, cost_quantity, xp_per_unit) VALUES + (3603221665, 2, 697030790, 265113466, 183980811, 8, 1, 100), + (3603221665, 3, 697030790, 265113466, 183980811, 8, 5, 100), + (3603221665, 4, 697030790, 265113466, 183980811, 8, 10, 100); diff --git a/Sunrise/resources/resource.h b/Sunrise/resources/resource.h index 52d392ba3..06370051a 100644 --- a/Sunrise/resources/resource.h +++ b/Sunrise/resources/resource.h @@ -47,3 +47,5 @@ /** Account preferences have their own schema and seed data in the save. */ #define IDR_ACCOUNT_SETTINGS_SCHEMA 109 #define IDR_ACCOUNT_SETTINGS_DEFAULTS 110 +/** Build-scoped vendor turn-in definitions are static content, not player-save rows. */ +#define IDR_VENDOR_REPUTATION_CONTENT 111 diff --git a/Sunrise/resources/sunrise.rc b/Sunrise/resources/sunrise.rc index 7becac890..3ea22d6b1 100644 --- a/Sunrise/resources/sunrise.rc +++ b/Sunrise/resources/sunrise.rc @@ -61,3 +61,4 @@ IDR_SQLITE_NOTICE RCDATA "../vendor/sqlite/NOTICE.txt" IDR_ACCOUNT_SETTINGS_SCHEMA RCDATA "database/account_settings_schema.sql" IDR_ACCOUNT_SETTINGS_DEFAULTS RCDATA "database/account_settings_defaults.sql" +IDR_VENDOR_REPUTATION_CONTENT RCDATA "database/vendor_reputation_content.sql" diff --git a/Sunrise/src/client/content/items/packages/internal.h b/Sunrise/src/client/content/items/packages/internal.h index 2a9b858be..2c30be2d9 100644 --- a/Sunrise/src/client/content/items/packages/internal.h +++ b/Sunrise/src/client/content/items/packages/internal.h @@ -24,6 +24,7 @@ #include "../../../../state/build_data/runtime.h" #include "../../../../state/build_data/season_pass/definition.h" #include "../../../../state/build_data/sobjects/sobject_catalog.h" +#include "package_reward_build.h" namespace sunrise::client::content::items::packages { @@ -70,9 +71,10 @@ static_assert(kUnmappedSlot /** Bank index per unlock slot, indexed by slot. The first mapping row of a slot wins. */ using SlotMap = std::array; -/** The four maps one root's two unlock mapping tables carry. 256 KiB together. */ +/** Saved-bank indices resolved from the root's flag and value mapping tables. */ struct SlotMaps { SlotMap accountFlag{}; + SlotMap profileFlag{}; SlotMap characterFlag{}; SlotMap accountValue{}; SlotMap characterValue{}; @@ -83,6 +85,8 @@ struct Storage { reader::Scratch scratch{}; /** Read once per root. Every domain resolves its unlock slots through these. */ SlotMaps slotMaps{}; + /** Reward pools, wrappers and condition tables read alongside the item walk. */ + RewardBuild rewardBuild{}; /** Node rows held until the value slot and owned records are resolved. */ std::array nodeRows{}; @@ -206,6 +210,9 @@ struct Storage { /** @return True when the catalyst catalog is published or cannot exist on this executable. */ [[nodiscard]] bool exotic_catalysts_settled() noexcept; +/** @return True when the reward graph is published or can never be. */ +[[nodiscard]] bool reward_definitions_settled() noexcept; + /** Adds one native definition index to the deduplicated request set. */ void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept; @@ -323,7 +330,7 @@ read_investment_constants(const reader::Source& source, /** * Reads one root's two unlock mapping tables into the pass slot maps. * @param source Package source. - * @param storage Pass storage receiving the four maps. + * @param storage Pass storage receiving the five maps. * @param root Investment root bytes. * @return True when both account maps read. A character map may stay unmapped. */ diff --git a/Sunrise/src/client/content/items/packages/package_item_build.cpp b/Sunrise/src/client/content/items/packages/package_item_build.cpp index e039beb82..2fe54603f 100644 --- a/Sunrise/src/client/content/items/packages/package_item_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_build.cpp @@ -9,6 +9,7 @@ #include "../../../../middleware/content/packages/tables/definition_index_table.h" #include "../../../../state/build_data/activities/activity_catalog.h" #include "../../../../state/build_data/runtime.h" +#include "../../../../state/build_data/vendors/vendor_gate_catalog.h" #include "../../activity/activity_catalog_build.h" #include "../../activity/entity_position_profile_build.h" #include "../../hash_names/hash_name_build.h" @@ -17,6 +18,7 @@ #include "../../vendors/vendor_build.h" #include "build.h" #include "internal.h" +#include "package_reward_build.h" #include "package_socket_plug_build.h" namespace sunrise::client::content::items::packages { @@ -34,7 +36,7 @@ namespace { && state::build_data::ability_buckets_ready() && state::build_data::socket_entry_buckets_ready() && state::build_data::progression_definitions_ready() - && state::build_data::season_pass_ready() + && state::build_data::season_pass_ready() && reward_definitions_settled() && state::build_data::repeatable_bounties_ready() && state::build_data::record_definitions_ready() && state::build_data::node_definitions_ready() @@ -48,7 +50,7 @@ namespace { bool ready() noexcept { return root_domains_ready() && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready() && state::build_data::hash_names_ready() - && state::build_data::vendor_catalog_ready() + && state::build_data::vendor_catalog_ready() && state::build_data::vendors::gates_ready() && content::activity::entity_position_profiles::ready() && (state::build_data::activities::ready() || state::build_data::activities::extraction_failed()); @@ -89,7 +91,7 @@ bool build() noexcept { return true; } } - if (root_domains_ready()) { + if (root_domains_ready() && state::build_data::vendors::gates_ready()) { SecureZeroMemory(&keys, sizeof keys); return ready(); } @@ -126,7 +128,8 @@ bool build() noexcept { if (!state::build_data::item_definitions_ready() || !state::build_data::record_definitions_ready() || !state::build_data::node_definitions_ready() - || !state::build_data::season_pass_ready() || !exotic_catalysts_settled()) { + || !state::build_data::season_pass_ready() || !reward_definitions_settled() + || !exotic_catalysts_settled() || !state::build_data::vendors::gates_ready()) { reason = "unlock_maps"; if (!read_unlock_slot_maps( source, storage, std::span{storage.root})) { @@ -184,11 +187,19 @@ bool build() noexcept { std::span(storage.progressionSteps).first(storage.progressionStepCount)); } } - if (!state::build_data::season_pass_ready() - && build_season_pass(source, storage, std::span{storage.root})) { - (void)state::build_data::publish_season_pass( + // Pass rows bind through the reward condition tables, which load before the pools. + if (!reward_definitions_settled() || !state::build_data::season_pass_ready()) { + (void)storage.rewardBuild.load( + source, storage.scratch, storage.root, storage.slotMaps); + } + if (!state::build_data::season_pass_ready() && storage.rewardBuild.conditions.loaded() + && build_season_pass(source, storage, std::span{storage.root}) + && !state::build_data::publish_season_pass( std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount), - std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount)); + std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount))) { + core::log::write(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=season_pass result=fail reason=publish"); } // Records are read before nodes: a node's lore-book flag and its parent bar come // from the records it owns, so the record rows must already be in pass storage. @@ -232,7 +243,9 @@ bool build() noexcept { table) && table.elementClass == tables::kItemIndexTableClass; } - if (located && build_item_rows(source, storage, table, rowCount, reason)) { + const bool rowsBuilt = located && !root_domains_ready() + && build_item_rows(source, storage, table, rowCount, reason); + if (rowsBuilt) { if (!build_material_requirements( source, storage, std::span{storage.root}, table.count)) { reason = "materials"; @@ -247,6 +260,15 @@ bool build() noexcept { std::span(storage.bountyRows).first(storage.bountyCount)); } } + if (root_domains_ready() && !state::build_data::vendors::gates_ready()) { + reason = "vendor_gates"; + const content::vendors::GateMaps maps{storage.slotMaps.accountFlag, + storage.slotMaps.profileFlag, + storage.slotMaps.characterFlag, + storage.slotMaps.accountValue, + storage.slotMaps.characterValue}; + (void)content::vendors::build_gates(source, storage.scratch, maps); + } } SecureZeroMemory(&keys, sizeof keys); const bool complete = ready(); diff --git a/Sunrise/src/client/content/items/packages/package_item_rows.cpp b/Sunrise/src/client/content/items/packages/package_item_rows.cpp index 1ffa18f8c..4c8db5eb0 100644 --- a/Sunrise/src/client/content/items/packages/package_item_rows.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_rows.cpp @@ -54,13 +54,16 @@ bool exotic_catalysts_settled() noexcept { * @param table Located item index array within storage.child. * @param rowCount Starts at zero; receives the rows retained even if publication fails. * @param reason Receives the last stage reached or its failure reason. - * @return True when this pass's required item domains are ready; failure may retain prior results. + * @return True when required non-reward item domains are ready. Failure may retain prior results. */ bool build_item_rows(const reader::Source& source, Storage& storage, const tables::Array& table, std::size_t& rowCount, const char*& reason) noexcept { + const bool needRewards = !reward_definitions_settled(); + const bool rewardStorageReady = + needRewards && storage.rewardBuild.begin_items(static_cast(table.count)); const bool needDefinitions = !state::build_data::item_definitions_ready(); const bool needDetails = !state::build_data::configured_item_details_ready(); const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready(); @@ -71,7 +74,7 @@ bool build_item_rows(const reader::Source& source, const bool needDetailRows = needDetails || needSocketRows; // Bucket equipment slots are derived from this same complete item walk, so a partial retry // must still revisit the table even when definitions and detail domains already published. - const bool needRows = needDefinitions || needDetailRows || needBuckets; + const bool needRows = needDefinitions || needDetailRows || needBuckets || rewardStorageReady; bool published = !needRows; if (retainDetails && storage.details.size() != kDetailCapacity) { storage.details.assign(kDetailCapacity, build_details::Definition{}); @@ -107,11 +110,23 @@ bool build_item_rows(const reader::Source& source, item.definitionHash = row.definitionHash; item.definitionIndex = static_cast(index); std::uint32_t itemClass = 0; - if (!reader::read_tag(source, storage.scratch, row.targetTag, storage.definition, itemClass) - || !tables::items::read_definition(std::span{storage.definition}, - item)) { + if (!reader::read_tag( + source, storage.scratch, row.targetTag, storage.definition, itemClass)) { + // The published catalog read this row before, so its reward waits for a retry; a row + // the catalog lacks stays pruned from both. + state::build_data::items::Definition cataloged{}; + if (rewardStorageReady + && state::build_data::find_item_definition_index(item.definitionIndex, cataloged)) { + storage.rewardBuild.item_unread(item.definitionIndex); + } + continue; + } + if (!tables::items::read_definition(std::span{storage.definition}, item)) { continue; } + if (rewardStorageReady && itemClass == tables::kItemDefinitionClass) { + storage.rewardBuild.item(item.definitionIndex, item.definitionHash, storage.definition); + } const std::uint32_t plugCategoryHash = corrected_plug_category(item.definitionHash, item.plugCategoryHash); build_items::QuestInitialization quest{}; @@ -175,6 +190,13 @@ bool build_item_rows(const reader::Source& source, published = state::build_data::publish_item_definitions(std::span(storage.rows).first(rowCount)); } + // A reward refusal is reported by RewardBuild and does not block the other item domains. + // Rewards publish only beside the item catalog and at its row count, so both omit the same + // unread rows. + const bool rewardsPublished = + !needRewards + || (rewardStorageReady && state::build_data::item_definitions_ready() + && storage.rewardBuild.publish(state::build_data::item_definition_count())); if (!published) { reason = !detailStorageReady ? "detail_storage" : !requestsFit ? "detail_capacity" @@ -301,6 +323,10 @@ bool build_item_rows(const reader::Source& source, report_ability_count(abilityCount); } } + // Named last; later stages overwrite the reason as they start. + if (published && !rewardsPublished) { + reason = "rewards"; + } return published && state::build_data::item_definitions_ready() && state::build_data::configured_item_details_ready() && state::build_data::socket_plug_rules_ready() && exotic_catalysts_settled() diff --git a/Sunrise/src/client/content/items/packages/package_node_build.cpp b/Sunrise/src/client/content/items/packages/package_node_build.cpp index a629db783..1584d0b39 100644 --- a/Sunrise/src/client/content/items/packages/package_node_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_node_build.cpp @@ -30,9 +30,10 @@ void clear_slot_map(SlotMap& output) noexcept { output.fill(kUnmappedSlot); } -/** Clears all four so an unread table cannot leave a prior pass's indexes addressable. */ +/** An unread table must not leave a prior pass's indices addressable. */ void clear_slot_maps(SlotMaps& maps) noexcept { clear_slot_map(maps.accountFlag); + clear_slot_map(maps.profileFlag); clear_slot_map(maps.characterFlag); clear_slot_map(maps.accountValue); clear_slot_map(maps.characterValue); @@ -113,7 +114,7 @@ read_slot_map(std::span blob, std::size_t descriptor, SlotMap& /** * A gate names a slot; its saved bank index is the mapping row that names that slot. * @param source Borrowed package source. - * @param storage Receives four slot maps and retained value-map bytes; may be partial on failure. + * @param storage Receives slot maps and retained value-map bytes; may be partial on failure. * @param root Investment root bytes naming the flag and value mapping tables. * @return True when both account maps read; a character map may remain unmapped. */ @@ -131,6 +132,7 @@ bool read_unlock_slot_maps(const reader::Source& source, const std::span flagMap{storage.child}; const bool flagMapRead = read_slot_map(flagMap, tables::kAccountFlagMapDescriptor, maps.accountFlag); + (void)read_slot_map(flagMap, tables::kProfileFlagMapDescriptor, maps.profileFlag); (void)read_slot_map(flagMap, tables::kCharacterFlagMapDescriptor, maps.characterFlag); std::uint32_t valueMapTag = 0; diff --git a/Sunrise/src/client/content/items/packages/package_progression_build.cpp b/Sunrise/src/client/content/items/packages/package_progression_build.cpp index 20cb3476f..5c03588a4 100644 --- a/Sunrise/src/client/content/items/packages/package_progression_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_progression_build.cpp @@ -36,15 +36,24 @@ bool build_progressions(const reader::Source& source, } const auto scope = std::to_integer(table[at + tables::kProgressionScopeOffset]); + const auto repeatLastStep = + std::to_integer(table[at + tables::kProgressionRepeatLastStepOffset]); + if (repeatLastStep > 1) { + count = 0; + stepCount = 0; + return false; + } domain::Definition& definition = output[count]; // Any scope beyond the two replicated objects belongs to an object this server does not // push, so it claims no slot in either array. + const auto resolvedScope = scope <= static_cast(domain::Scope::character) + ? static_cast(scope) + : domain::Scope::unreplicated; definition = {static_cast(row), static_cast(stepCount), 0, - scope <= static_cast(domain::Scope::character) - ? static_cast(scope) - : domain::Scope::unreplicated}; + resolvedScope, + repeatLastStep != 0}; tables::Array ladder{}; if (!tables::find_optional_array_at(table, at + tables::kProgressionStepField, ladder)) { count = 0; diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp new file mode 100644 index 000000000..3d71ca326 --- /dev/null +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -0,0 +1,811 @@ +#include "package_reward_build.h" + +#include +#include +#include + +#include "../../../../core/logging/log.h" +#include "../../../../middleware/content/packages/tables/definition_index_table.h" +#include "../../../../middleware/content/packages/tables/field_reader.h" +#include "../../../../state/build_data/runtime.h" +#include "../../../../state/unlocks/unlocks_expression.h" +#include "internal.h" + +namespace sunrise::client::content::items::packages { +namespace { + +namespace reader = middleware::content::packages::reader; +namespace tables = middleware::content::packages::tables; +namespace domain = state::build_data::rewards; +namespace unlocks = state::unlocks; + +/** Investment-root slots identify reward pools and unlock-slot bindings. */ +constexpr std::size_t kSupplementalRewardSlot = 84; +constexpr std::size_t kPoolSlot = 88; +constexpr std::size_t kExpressionSlot = 109; +constexpr std::size_t kFlagSlot = 112; +constexpr std::size_t kValueSlot = 114; +/** Only the omitted-bank sentinel permits a no-op; other invalid tags leave effects unresolved. */ +constexpr std::uint32_t kAbsentTableTag = 0xFFFFFFFFU; +/** Native reward schema classes and fixed row sizes. */ +constexpr std::uint32_t kPoolClass = 0x80807553U; +constexpr std::uint32_t kPoolRowClass = 0x8080748CU; +constexpr std::uint32_t kEntryClass = 0x8080748EU; +constexpr std::uint32_t kExpressionClass = 0x80807D31U; +constexpr std::uint32_t kModifierClass = 0x80807490U; +constexpr std::uint32_t kSocketClass = 0x80803062U; +constexpr std::uint32_t kWrapperClass = 0x808077CCU; +constexpr std::uint32_t kSelectionClass = 0x808077CFU; +constexpr std::uint32_t kFlagTableClass = 0x80807D49U; +constexpr std::uint32_t kFlagRowClass = 0x80807D4FU; +constexpr std::uint32_t kValueTableClass = 0x80807C92U; +constexpr std::uint32_t kValueRowClass = 0x80807C96U; +constexpr std::uint32_t kExpressionTableClass = 0x80807C49U; +constexpr std::uint32_t kExpressionRowClass = 0x80807C4FU; +constexpr std::uint32_t kConditionClass = 0x80807D2FU; +constexpr std::size_t kPoolStride = 24; +constexpr std::size_t kEntryStride = 80; +constexpr std::size_t kModifierStride = 24; +constexpr std::size_t kBindingStride = 8; +constexpr std::size_t kExpressionRowStride = 24; +constexpr std::size_t kSocketStride = 12; +constexpr std::size_t kSelectionStride = 12; +/** Item headers hold a relative wrapper pointer and an acquired-unlock slot. */ +constexpr std::size_t kWrapperField = 0x58; +constexpr std::size_t kAcquiredFlagField = 0xDA; + +/** Entry +10 selects a supplemental reward, not an item or nested pool. */ +constexpr std::size_t kEntryQuantityOffset = 4; +constexpr std::size_t kEntryPoolOffset = 8; +constexpr std::size_t kEntrySupplementalOffset = 10; +constexpr std::size_t kEntryCategoryOffset = 20; +constexpr std::size_t kEntryWeightOffset = 24; +constexpr std::size_t kEntryConditionOffset = 32; +constexpr std::size_t kEntryModifiersOffset = 48; +constexpr std::size_t kEntrySocketsOffset = 64; +constexpr std::size_t kModifierValueIndexOffset = 16; +constexpr std::size_t kModifierValueOffset = 20; +constexpr std::size_t kPoolEntriesOffset = 8; +constexpr std::size_t kExpressionBodyOffset = 8; +constexpr std::size_t kWrapperSelectionsOffset = 8; +constexpr std::size_t kWrapperFlagsOffset = 24; +constexpr std::size_t kSelectionCountOffset = 4; +constexpr std::size_t kSocketPlugOffset = 2; +constexpr std::size_t kSocketPlugSetOffset = 4; +constexpr std::size_t kSocketRollSetOffset = 6; +constexpr std::size_t kSocketSelectionOffset = 8; +/** Relative definition bodies are preceded by their four-byte schema class. */ +constexpr std::size_t kDefinitionClassPrefixSize = sizeof(std::uint32_t); + +/** Class rows name a default finisher whose use gate supplies the computed class flag. */ +constexpr std::size_t kClassSlot = 12; +constexpr std::size_t kClassStride = 32; +constexpr std::size_t kDefaultFinisherOffset = 24; +constexpr std::uint32_t kClassTable = 0x808075BEU; +constexpr std::uint32_t kClassRow = 0x808074FAU; +constexpr std::size_t kUseConditionPointer = 24; +constexpr std::uint32_t kUseConditionClass = 0x80802980U; + +/** Set when the installed data refuses the reward tables or graph; no retry can change that. */ +bool g_rewardsUnsupported = false; + +/** Logs one refused reward load, sizing or publication. */ +void report_refusal(const char* reason) noexcept { + core::log::writef(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=rewards result=fail reason=%s", + reason); +} + +/** Logs a refusal the installed data decides and settles the domain once. */ +void settle_unsupported(const char* reason) noexcept { + report_refusal(reason); + if (!g_rewardsUnsupported) { + g_rewardsUnsupported = true; + core::log::write(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=rewards result=unsupported"); + } +} + +/** An unread item row keeps a zero hash. */ +[[nodiscard]] bool item_read(std::span items, std::uint16_t index) noexcept { + return index < items.size() && items[index].definitionHash != 0; +} + +/** Appends within a bank's capacity; an allocation failure is refused rather than thrown. */ +template bool append(std::vector& bank, T value, std::size_t capacity) noexcept { + if (bank.size() >= capacity) { + return false; + } + try { + bank.push_back(value); + return true; + } catch (...) { + return false; + } +} + +/** Package reads can fail temporarily. Only decoded schema failures settle a domain. */ +enum class TableRead { loaded, readFailed, unsupported }; + +/** Reads the table an investment-root slot names, checking its class when one is expected. */ +TableRead root_table(const reader::Source& source, + reader::Scratch& scratch, + std::span root, + std::size_t slot, + std::vector& blob, + std::uint32_t expectedClass) noexcept { + std::uint32_t tag = 0; + std::uint32_t cls = 0; + if (!tables::slot_tag(root, slot, tag) || tables::package_of(tag) == tables::kAbsentPackageId) { + return TableRead::unsupported; + } + if (!reader::read_tag(source, scratch, tag, blob, cls)) { + return TableRead::readFailed; + } + return expectedClass == 0 || cls == expectedClass ? TableRead::loaded : TableRead::unsupported; +} + +/** Required reward tables settle only after a decoded schema failure, leaving reads retryable. */ +bool load_reward_table(const reader::Source& source, + reader::Scratch& scratch, + std::span root, + std::size_t slot, + std::vector& blob, + std::uint32_t expectedClass, + const char* reason) noexcept { + const auto result = root_table(source, scratch, root, slot, blob, expectedClass); + if (result == TableRead::unsupported) { + settle_unsupported(reason); + } else if (result == TableRead::readFailed) { + report_refusal(reason); + } + return result == TableRead::loaded; +} + +/** Reads one entry's fixed fields; a negative or non-finite weight is refused. */ +bool read_reward_entry(std::span blob, + std::size_t at, + domain::Entry& entry) noexcept { + return tables::read(blob, at, entry.itemIndex) + && tables::read(blob, at + kEntryQuantityOffset, entry.quantity) + && tables::read(blob, at + kEntryPoolOffset, entry.poolIndex) + && tables::read(blob, at + kEntrySupplementalOffset, entry.supplementalIndex) + && tables::read(blob, at + kEntryCategoryOffset, entry.categoryHash) + && tables::read(blob, at + kEntryWeightOffset, entry.weight) + && std::isfinite(entry.weight) && entry.weight >= 0; +} + +/** Reads the single flag that gates use of the class's default finisher. */ +bool read_class_flag(std::span blob, std::uint16_t& output) noexcept { + std::size_t at = 0; + std::uint32_t cls = 0; + tables::Array expression{}; + if (!tables::relative(blob, kUseConditionPointer, at) || at < kDefinitionClassPrefixSize + || !tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kUseConditionClass + || !tables::read_array( + blob, at, kExpressionClass, tables::kUnlockInstructionStride, expression) + || expression.count != 1) { + return false; + } + std::uint32_t opcode = 0; + std::uint32_t flag = domain::kAbsent; + if (!tables::read(blob, expression.dataOffset, opcode) + || opcode != tables::kUnlockReadFlagOpcode + || !tables::read( + blob, expression.dataOffset + tables::kUnlockInstructionOperandOffset, flag) + || flag >= domain::kAbsent) { + return false; + } + output = static_cast(flag); + return true; +} + +} // namespace + +/** @return True when the reward graph is published or can never be. */ +bool reward_definitions_settled() noexcept { + return state::build_data::reward_definitions_ready() || g_rewardsUnsupported; +} + +bool RewardBuild::entry(std::span blob, + std::size_t at, + BankFailure& failure, + const char*& reason) noexcept { + domain::Entry out{}; + failure = BankFailure::none; + if (!read_reward_entry(blob, at, out) + || !conditions.read( + blob, at + kEntryConditionOffset, instructions_, out.condition, failure)) { + reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage"; + return false; + } + out.supplementalMissing = supplementalMissing_ && out.supplementalIndex != domain::kAbsent; + tables::Array rows{}; + if (!tables::read_array( + blob, at + kEntryModifiersOffset, kModifierClass, kModifierStride, rows)) { + return false; + } + out.modifiers = {static_cast(modifiers_.size()), + static_cast(rows.count)}; + for (std::size_t i = 0; i < rows.count; ++i) { + const auto offset = rows.dataOffset + i * kModifierStride; + domain::Modifier modifier{}; + if (!conditions.read(blob, offset, instructions_, modifier.condition, failure) + || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex) + || !tables::read(blob, offset + kModifierValueOffset, modifier.value) + || !std::isfinite(modifier.value)) { + reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage"; + return false; + } + if (modifiers_.size() >= domain::kModifierCapacity) { + failure = BankFailure::full; + reason = "modifier_capacity"; + return false; + } + if (!append(modifiers_, modifier, domain::kModifierCapacity)) { + failure = BankFailure::storage; + reason = "modifier_storage"; + return false; + } + } + std::array overrides{}; + std::size_t count = 0; + if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count)) { + return false; + } + if (count > domain::kSocketOverrideCapacity - sockets_.size()) { + failure = BankFailure::full; + reason = "socket_capacity"; + return false; + } + out.sockets = {static_cast(sockets_.size()), static_cast(count)}; + for (std::size_t i = 0; i < count; ++i) { + if (overrides[i].socketType == domain::kAbsent) { + return false; + } + if (!append(sockets_, overrides[i], domain::kSocketOverrideCapacity)) { + failure = BankFailure::storage; + reason = "socket_storage"; + return false; + } + } + if (entries_.size() >= domain::kEntryCapacity) { + failure = BankFailure::full; + reason = "entry_capacity"; + return false; + } + if (!append(entries_, out, domain::kEntryCapacity)) { + failure = BankFailure::storage; + reason = "entry_storage"; + return false; + } + return true; +} + +bool RewardConditions::load_class_flags(const reader::Source& source, + reader::Scratch& scratch, + std::span root) noexcept { + classFlags_.fill(domain::kAbsent); + std::vector classes; + std::vector index; + std::vector blob; + tables::Array classRows{}; + tables::Array itemRows{}; + if (!load_reward_table(source, scratch, root, kClassSlot, classes, kClassTable, "class_table") + || !load_reward_table( + source, scratch, root, tables::kItemTableSlot, index, 0, "class_item_table")) { + return false; + } + if (!tables::read_array( + classes, tables::kTableArrayDescriptor, kClassRow, kClassStride, classRows) + || classRows.count != classFlags_.size() + || !tables::read_array(index, + tables::kTableArrayDescriptor, + tables::kItemIndexTableClass, + tables::kItemIndexRowStride, + itemRows)) { + settle_unsupported("class_schema"); + return false; + } + for (std::size_t i = 0; i < classFlags_.size(); ++i) { + std::uint16_t itemIndex = domain::kAbsent; + tables::IndexRow item{}; + std::uint32_t cls = 0; + if (!tables::read(std::span{classes}, + classRows.dataOffset + i * kClassStride + kDefaultFinisherOffset, + itemIndex) + || !tables::index_row(index, itemRows, itemIndex, item)) { + settle_unsupported("class_finisher_index"); + return false; + } + if (!reader::read_tag(source, scratch, item.targetTag, blob, cls)) { + core::log::writef(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=class_flags class=%zu result=fail reason=finisher_read", + i); + return false; + } + if (cls != tables::kItemDefinitionClass || !read_class_flag(blob, classFlags_[i])) { + settle_unsupported("class_finisher_schema"); + return false; + } + } + return true; +} + +bool RewardConditions::load(const reader::Source& source, + reader::Scratch& scratch, + std::span root, + const SlotMaps& maps) noexcept { + loaded_ = false; + maps_ = &maps; + if (!load_class_flags(source, scratch, root) + || !load_reward_table( + source, scratch, root, kFlagSlot, flags_, kFlagTableClass, "flag_table") + || !load_reward_table( + source, scratch, root, kValueSlot, values_, kValueTableClass, "value_table") + || !load_reward_table(source, + scratch, + root, + kExpressionSlot, + expressions_, + kExpressionTableClass, + "expression_table")) { + return false; + } + // Shared expressions are expanded before the runtime evaluates reward conditions. + if (!tables::read_array( + flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_) + || !tables::read_array( + values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_) + || !tables::read_array(expressions_, + tables::kTableArrayDescriptor, + kExpressionRowClass, + kExpressionRowStride, + expressionRows_)) { + settle_unsupported("condition_schema"); + return false; + } + loaded_ = true; + return true; +} + +bool RewardConditions::loaded() const noexcept { + return loaded_; +} + +bool RewardConditions::bind(std::uint32_t native, + std::uint32_t operand, + domain::Instruction& instruction) const noexcept { + const bool flag = native == tables::kUnlockReadFlagOpcode; + if (!flag && native != tables::kUnlockReadValueOpcode) { + instruction = {{}, unlocks::Bank::none, operand}; + return unlocks::decode_opcode(native, instruction.opcode); + } + const auto& rows = flag ? flagRows_ : valueRows_; + const std::span blob = flag ? flags_ : values_; + std::uint32_t hash = 0; + if (operand >= rows.count + || !tables::read(blob, rows.dataOffset + operand * kBindingStride, hash)) { + return false; + } + instruction.opcode = flag ? unlocks::Opcode::flag : unlocks::Opcode::loadValue; + if (flag) { + for (std::size_t characterClass = 0; characterClass < classFlags_.size(); + ++characterClass) { + if (classFlags_[characterClass] != domain::kAbsent + && operand == classFlags_[characterClass]) { + instruction.bank = unlocks::Bank::characterClass; + instruction.operand = static_cast(characterClass); + return true; + } + } + } + const auto slot = static_cast(operand); + const auto accountIndex = bank_index(flag ? maps_->accountFlag : maps_->accountValue, slot); + const auto profileIndex = flag ? bank_index(maps_->profileFlag, slot) : kUnmappedSlot; + const auto characterIndex = + bank_index(flag ? maps_->characterFlag : maps_->characterValue, slot); + if (accountIndex != kUnmappedSlot) { + instruction.bank = unlocks::Bank::account; + instruction.operand = accountIndex; + } else if (profileIndex != kUnmappedSlot) { + instruction.bank = unlocks::Bank::profile; + instruction.operand = profileIndex; + } else if (characterIndex != kUnmappedSlot) { + instruction.bank = unlocks::Bank::character; + instruction.operand = characterIndex; + } else { + instruction.bank = unlocks::Bank::external; + instruction.operand = hash; + } + return true; +} + +bool RewardConditions::append_expression(std::span blob, + std::size_t at, + std::vector& bank, + std::size_t depth, + BankFailure& failure) const noexcept { + tables::Array rows{}; + if (depth >= domain::kTraversalDepth + || !tables::read_array( + blob, at, kExpressionClass, tables::kUnlockInstructionStride, rows)) { + return false; + } + for (std::size_t i = 0; i < rows.count; ++i) { + const std::size_t row = rows.dataOffset + i * tables::kUnlockInstructionStride; + std::uint32_t native = 0; + std::uint32_t operand = 0; + if (!tables::read(blob, row, native) + || !tables::read(blob, row + tables::kUnlockInstructionOperandOffset, operand)) { + return false; + } + if (native == tables::kUnlockExpressionOpcode) { + const auto before = bank.size(); + if (operand >= expressionRows_.count + || !append_expression(expressions_, + expressionRows_.dataOffset + operand * kExpressionRowStride + + kExpressionBodyOffset, + bank, + depth + 1, + failure) + || bank.size() == before) { + return false; + } + continue; + } + domain::Instruction instruction{}; + if (!bind(native, operand, instruction) || !unlocks::valid(instruction)) { + return false; + } + if (bank.size() >= domain::kInstructionCapacity) { + failure = BankFailure::full; + return false; + } + if (!append(bank, instruction, domain::kInstructionCapacity)) { + failure = BankFailure::storage; + return false; + } + } + return true; +} + +bool RewardConditions::read(std::span blob, + std::size_t at, + std::vector& bank, + domain::Range& range, + BankFailure& failure) const noexcept { + const auto first = bank.size(); + failure = BankFailure::none; + if (!append_expression(blob, at, bank, 0, failure)) { + bank.resize(first); + return false; + } + range = {static_cast(first), static_cast(bank.size() - first)}; + return true; +} + +bool RewardConditions::read_list(std::span blob, + std::size_t at, + std::span output, + std::size_t& count, + BankFailure& failure) const noexcept { + count = 0; + failure = BankFailure::none; + tables::Array rows{}; + std::vector instructions; + if (!tables::read_array(blob, at, kConditionClass, tables::kUnlockExpressionFieldSize, rows)) { + return false; + } + for (std::size_t i = 0; i < rows.count; ++i) { + domain::Range expression{}; + if (!read(blob, + rows.dataOffset + i * tables::kUnlockExpressionFieldSize, + instructions, + expression, + failure) + || expression.count == 0) { + return false; + } + // Every expression attached to a progression reward must hold. + if (i != 0) { + if (instructions.size() >= output.size()) { + return false; + } + if (!append(instructions, + domain::Instruction{unlocks::Opcode::logicalAnd, unlocks::Bank::none, 0}, + output.size())) { + failure = BankFailure::storage; + return false; + } + } + if (instructions.size() > output.size()) { + return false; + } + } + std::copy(instructions.begin(), instructions.end(), output.begin()); + count = instructions.size(); + return true; +} + +bool read_reward_sockets(std::span blob, + std::size_t at, + std::span output, + std::size_t& count) noexcept { + count = 0; + tables::Array rows{}; + if (!tables::read_array(blob, at, kSocketClass, kSocketStride, rows) + || rows.count > output.size()) { + return false; + } + for (std::size_t i = 0; i < rows.count; ++i) { + const std::size_t p = rows.dataOffset + i * kSocketStride; + auto& socket = output[i]; + if (!tables::read(blob, p, socket.socketType) + || !tables::read(blob, p + kSocketPlugOffset, socket.plugItem) + || !tables::read(blob, p + kSocketPlugSetOffset, socket.plugSet) + || !tables::read(blob, p + kSocketRollSetOffset, socket.rollSet) + || !tables::read(blob, p + kSocketSelectionOffset, socket.selection)) { + return false; + } + } + count = static_cast(rows.count); + return true; +} + +bool RewardBuild::load(const reader::Source& source, + reader::Scratch& scratch, + std::span root, + const SlotMaps& maps) noexcept { + loaded_ = false; + supplementalMissing_ = false; + pools_.clear(); + entries_.clear(); + instructions_.clear(); + modifiers_.clear(); + sockets_.clear(); + std::vector blob; + tables::Array rows{}; + std::uint32_t supplementalTag = 0; + // Conditions load first; the season pass binds through them even if the pools are refused. + if (!conditions.load(source, scratch, root, maps)) { + return false; + } + if (!tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag)) { + settle_unsupported("supplemental_slot"); + return false; + } + if (!load_reward_table(source, scratch, root, kPoolSlot, blob, kPoolClass, "pool_table")) { + return false; + } + if (!tables::read_array( + blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows)) { + settle_unsupported("pool_schema"); + return false; + } + if (rows.count == 0 || rows.count > domain::kPoolCapacity) { + settle_unsupported("pool_count"); + return false; + } + supplementalMissing_ = supplementalTag == kAbsentTableTag; + std::size_t skipped = 0; + for (std::size_t i = 0; i < rows.count; ++i) { + const auto at = rows.dataOffset + i * kPoolStride; + domain::Pool pool{}; + tables::Array members{}; + if (tables::read(blob, at, pool.definitionHash) && pool.definitionHash != 0 + && tables::read_array( + blob, at + kPoolEntriesOffset, kEntryClass, kEntryStride, members)) { + pool.entries.first = static_cast(entries_.size()); + for (std::size_t j = 0; j < members.count; ++j) { + const auto beforeInstructions = instructions_.size(); + const auto beforeModifiers = modifiers_.size(); + const auto beforeSockets = sockets_.size(); + auto failure = BankFailure::none; + const char* reason = nullptr; + if (!entry(blob, members.dataOffset + j * kEntryStride, failure, reason)) { + // Skipping a bank failure would publish a pool missing some of its entries. + if (failure == BankFailure::full) { + settle_unsupported(reason); + return false; + } + if (failure == BankFailure::storage) { + report_refusal(reason); + return false; + } + instructions_.resize(beforeInstructions); + modifiers_.resize(beforeModifiers); + sockets_.resize(beforeSockets); + ++skipped; + } + } + pool.entries.count = static_cast(entries_.size()) - pool.entries.first; + } else { + pool = {}; + ++skipped; + } + if (!append(pools_, pool, domain::kPoolCapacity)) { + report_refusal("pool_storage"); + return false; + } + } + core::log::writef(core::log::Channel::client, + skipped == 0 ? core::log::Level::info : core::log::Level::warn, + "ev=pkg stage=rewards pools=%zu entries=%zu instructions=%zu modifiers=%zu " + "sockets=%zu skipped=%zu", + pools_.size(), + entries_.size(), + instructions_.size(), + modifiers_.size(), + sockets_.size(), + skipped); + loaded_ = true; + return true; +} + +bool RewardBuild::read_item(std::uint32_t hash, + std::span blob, + domain::Item& item) noexcept { + std::uint16_t acquired = domain::kAbsent; + std::int64_t wrapper = 0; + std::uint32_t cls = 0; + if (!tables::read(blob, kWrapperField, wrapper) + || !tables::read(blob, kAcquiredFlagField, acquired)) { + return false; + } + item.definitionHash = hash; + if (acquired != domain::kAbsent) { + domain::Instruction flag{}; + if (!conditions.bind(tables::kUnlockReadFlagOpcode, acquired, flag) + || !unlocks::valid(flag)) { + // The grant is kept; only its acquisition flag is unknown. + ++unboundAcquiredFlags_; + } else if (flag.bank == unlocks::Bank::account) { + item.acquiredFlag = static_cast(flag.operand); + } else { + ++discardedAcquiredFlags_; + } + } + // A zero delta means the item is not a wrapper. + if (wrapper != 0) { + std::size_t at = 0; + tables::Array selections{}; + if (!tables::relative(blob, kWrapperField, at) || at < kDefinitionClassPrefixSize + || !tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kWrapperClass + || !tables::read(blob, at, item.poolIndex) + || !tables::read(blob, at + kWrapperFlagsOffset, item.flags) + || !tables::read_array( + blob, at + kWrapperSelectionsOffset, kSelectionClass, kSelectionStride, selections) + || selections.count > item.selections.size()) { + return false; + } + item.selectionCount = static_cast(selections.count); + for (std::size_t j = 0; j < selections.count; ++j) { + auto& selection = item.selections[j]; + const auto p = selections.dataOffset + j * kSelectionStride; + if (!tables::read(blob, p, selection.categoryHash) + || !tables::read(blob, p + kSelectionCountOffset, selection.count)) { + return false; + } + } + } + return true; +} + +bool RewardBuild::begin_items(std::size_t count) noexcept { + unboundAcquiredFlags_ = 0; + discardedAcquiredFlags_ = 0; + unreadItems_ = 0; + // A refused load has already reported itself. + if (!loaded_) { + return false; + } + try { + if (count == 0 || count > domain::kItemCapacity) { + report_refusal("item_storage"); + return false; + } + items_.assign(count, {}); + return true; + } catch (...) { + report_refusal("item_storage"); + return false; + } +} + +void RewardBuild::item(std::uint16_t index, + std::uint32_t hash, + std::span blob) noexcept { + domain::Item parsed{}; + if (index < items_.size() && read_item(hash, blob, parsed)) { + items_[index] = parsed; + } +} + +void RewardBuild::item_unread(std::uint16_t index) noexcept { + if (unreadItems_++ == 0) { + firstUnreadItem_ = index; + } +} + +bool RewardBuild::publish(std::size_t catalogItemCount) noexcept { + if (!loaded_) { + return false; + } + // Publishing now would prune every entry that names an unread item. + if (unreadItems_ != 0) { + core::log::writef(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=rewards result=fail reason=item_read unread_items=%zu " + "first_index=%u", + unreadItems_, + static_cast(firstUnreadItem_)); + return false; + } + // A catalog that lost trailing rows is shorter than the table, and the cache pairs each of its + // items with one reward row. + if (catalogItemCount < items_.size()) { + items_.resize(catalogItemCount); + } + // Cleared first so the entry pass also drops draws of a cleared wrapper. + for (auto& item : items_) { + if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools_.size()) { + item = {}; + } + } + std::size_t write = 0; + std::size_t socketWrite = 0; + for (auto& pool : pools_) { + const auto range = pool.entries; + pool.entries.first = static_cast(write); + for (std::size_t i = range.first; i < range.first + range.count; ++i) { + const auto& row = entries_[i]; + bool valid = (row.itemIndex == domain::kAbsent || item_read(items_, row.itemIndex)) + && (row.poolIndex == domain::kAbsent || row.poolIndex < pools_.size()); + for (const auto& socket : + std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) { + valid &= + domain::valid_socket(socket, items_.size()) + && (socket.plugItem == domain::kAbsent || item_read(items_, socket.plugItem)); + } + if (valid) { + auto retained = row; + retained.sockets.first = static_cast(socketWrite); + for (const auto& socket : + std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) { + sockets_[socketWrite++] = socket; + } + entries_[write++] = retained; + } + } + pool.entries.count = static_cast(write) - pool.entries.first; + } + const std::size_t droppedEntries = entries_.size() - write; + entries_.resize(write); + sockets_.resize(socketWrite); + if (unboundAcquiredFlags_ != 0 || discardedAcquiredFlags_ != 0 || droppedEntries != 0) { + core::log::writef(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=rewards result=partial unbound_acquired_flags=%zu " + "discarded_acquired_flags=%zu dropped_entries=%zu", + unboundAcquiredFlags_, + discardedAcquiredFlags_, + droppedEntries); + } + const domain::View graph{pools_, entries_, items_, instructions_, modifiers_, sockets_}; + if (!state::build_data::valid_reward_definitions(graph)) { + settle_unsupported("graph"); + return false; + } + // A cache write can still fail, so this stays retryable. + if (!state::build_data::publish_reward_definitions(graph)) { + report_refusal("publish"); + return false; + } + return true; +} + +} // namespace sunrise::client::content::items::packages diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.h b/Sunrise/src/client/content/items/packages/package_reward_build.h new file mode 100644 index 000000000..08015f885 --- /dev/null +++ b/Sunrise/src/client/content/items/packages/package_reward_build.h @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../../../../middleware/content/packages/reader/reader.h" +#include "../../../../middleware/content/packages/tables/definition_index_table.h" +#include "../../../../state/account/account_state.h" +#include "../../../../state/build_data/rewards/definition.h" + +namespace sunrise::client::content::items::packages { + +struct SlotMaps; + +/** A full reward bank settles the domain; failed storage is retried on a later pass. */ +enum class BankFailure : std::uint8_t { none, full, storage }; + +/** Resolves expression references and unlock slots once for a package pass. */ +class RewardConditions { +public: + /** Reads the root's flag, value, expression and class tables; false when one is missing. */ + [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source, + middleware::content::packages::reader::Scratch& scratch, + std::span root, + const SlotMaps& maps) noexcept; + /** @return True when the last load() succeeded. */ + [[nodiscard]] bool loaded() const noexcept; + /** Binds one native instruction; flag and value slots resolve to their saved bank. */ + [[nodiscard]] bool bind(std::uint32_t native, + std::uint32_t operand, + state::build_data::rewards::Instruction& instruction) const noexcept; + /** Appends one expanded expression; the bank is unchanged on failure. */ + [[nodiscard]] bool read(std::span blob, + std::size_t at, + std::vector& bank, + state::build_data::rewards::Range& range, + BankFailure& failure) const noexcept; + /** Expands a list of expressions into one program where every expression must hold. */ + [[nodiscard]] bool read_list(std::span blob, + std::size_t at, + std::span output, + std::size_t& count, + BankFailure& failure) const noexcept; + +private: + [[nodiscard]] bool append_expression(std::span blob, + std::size_t at, + std::vector& bank, + std::size_t depth, + BankFailure& failure) const noexcept; + /** Reads every class binding before any reward condition can be published. */ + [[nodiscard]] bool load_class_flags(const middleware::content::packages::reader::Source& source, + middleware::content::packages::reader::Scratch& scratch, + std::span root) noexcept; + std::array classFlags_{}; + const SlotMaps* maps_{}; + bool loaded_{}; + std::vector flags_; + std::vector values_; + std::vector expressions_; + middleware::content::packages::tables::Array flagRows_{}; + middleware::content::packages::tables::Array valueRows_{}; + middleware::content::packages::tables::Array expressionRows_{}; +}; + +/** Reward metadata collected alongside the existing item-definition walk. */ +class RewardBuild { +public: + RewardConditions conditions; + /** Reads every reward pool; unreadable entries are skipped, a full bank refuses the load. */ + [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source, + middleware::content::packages::reader::Scratch& scratch, + std::span root, + const SlotMaps& maps) noexcept; + /** Sizes the dense item rows before the item walk. */ + [[nodiscard]] bool begin_items(std::size_t count) noexcept; + /** Records one item's wrapper and acquisition flag; an unreadable item stays unavailable. */ + void item(std::uint16_t index, std::uint32_t hash, std::span blob) noexcept; + /** Notes an item whose definition failed to read; publication waits for a clean walk. */ + void item_unread(std::uint16_t index) noexcept; + /** Trims item rows to the catalog, drops rows naming unread items or pools, then publishes. */ + [[nodiscard]] bool publish(std::size_t catalogItemCount) noexcept; + +private: + /** Appends one entry; a bank failure also names the bank and what refused it. */ + [[nodiscard]] bool entry(std::span blob, + std::size_t at, + BankFailure& failure, + const char*& reason) noexcept; + [[nodiscard]] bool read_item(std::uint32_t hash, + std::span blob, + state::build_data::rewards::Item& item) noexcept; + bool loaded_{}; + bool supplementalMissing_{}; + std::size_t unboundAcquiredFlags_{}; + std::size_t discardedAcquiredFlags_{}; + std::size_t unreadItems_{}; + std::uint16_t firstUnreadItem_{}; + std::vector pools_; + std::vector entries_; + std::vector items_; + std::vector instructions_; + std::vector modifiers_; + std::vector sockets_; +}; + +/** Progression rewards and pool entries use the same socket-override layout. */ +[[nodiscard]] bool read_reward_sockets(std::span blob, + std::size_t at, + std::span output, + std::size_t& count) noexcept; + +} // namespace sunrise::client::content::items::packages diff --git a/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp b/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp index 2dcc13bd9..0d207bbac 100644 --- a/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp @@ -1,35 +1,24 @@ #include -#include #include +#include "../../../../core/logging/log.h" +#include "../../../../middleware/content/packages/tables/field_reader.h" +#include "../../../../state/progression/season_pass_reward_catalog.h" #include "internal.h" +#include "package_reward_build.h" namespace sunrise::client::content::items::packages { namespace { namespace domain = state::build_data::season_pass; -/** Account progression the installed Season of Arrivals pass declares its rewards on. */ -constexpr std::uint16_t kPassProgressionIndex = 40; - -/** @param blob Source bytes. @param offset Field offset. @param value Receives the field. */ -template -[[nodiscard]] bool -read(std::span blob, std::size_t offset, Value& value) noexcept { - if (offset > blob.size() || blob.size() - offset < sizeof value) { - return false; - } - std::memcpy(&value, blob.data() + offset, sizeof value); - return true; -} - /** - * Reads the item set one reward's wrapper item opens into. + * Reads the item set declared by one reward wrapper. * @param definition Whole item definition blob. * @param itemTable Item index table blob. * @param itemRows Located item index array. - * @param package Receives the wrapper's items, or stays empty when the item opens into nothing. - * @return True when the item declares no set, or declares one that reads back whole. + * @param package Empty destination receiving resolved item hashes; may be partial on failure. + * @return True when the item declares no set, or its whole set resolves. */ [[nodiscard]] bool read_package(std::span definition, std::span itemTable, @@ -50,9 +39,10 @@ read(std::span blob, std::size_t offset, Value& value) noexcept for (std::uint64_t member = 0; member < set.count; ++member) { std::uint16_t itemIndex = 0; tables::IndexRow entry{}; - if (!read(definition, - set.dataOffset + static_cast(member) * tables::kGearsetItemStride, - itemIndex) + if (!tables::read(definition, + set.dataOffset + + static_cast(member) * tables::kGearsetItemStride, + itemIndex) || !tables::index_row(itemTable, itemRows, itemIndex, entry)) { return false; } @@ -61,13 +51,89 @@ read(std::span blob, std::size_t offset, Value& value) noexcept return true; } +/** + * Reads one pass row: item, rank, quantity and claim flag, then its sockets and conditions. + * @param storage Build storage providing item rows, slot maps, and condition decoding. + * @param progressionTable Progression table containing the row. + * @param itemRows Located item index array. + * @param at Byte offset of the reward row. + * @param reward Receives the decoded row; may be partial on failure. + * @param failure Receives a condition-bank storage failure when one occurs. + * @return False when any field is unreadable or out of range. + */ +bool read_season_reward(Storage& storage, + std::span progressionTable, + const tables::Array& itemRows, + std::size_t at, + domain::Reward& reward, + BankFailure& failure) noexcept { + std::uint32_t rank = 0; + std::uint32_t itemIndex = 0; + std::uint32_t claimSlot = 0; + if (!tables::read(progressionTable, at + tables::kProgressionRewardRankOffset, rank) + || !tables::read( + progressionTable, at + tables::kProgressionRewardItemIndexOffset, itemIndex) + || !tables::read( + progressionTable, at + tables::kProgressionRewardQuantityOffset, reward.quantity) + || !tables::read( + progressionTable, at + tables::kProgressionRewardClaimSlotOffset, claimSlot)) { + return false; + } + + // Native fields are 32-bit, the stored row keeps the narrower native index widths. + tables::IndexRow entry{}; + if (rank > (std::numeric_limits::max)() + || itemIndex > (std::numeric_limits::max)() + || claimSlot > (std::numeric_limits::max)() + || !tables::index_row(storage.itemIndexTable, itemRows, itemIndex, entry)) { + return false; + } + reward.itemHash = entry.definitionHash; + reward.itemIndex = static_cast(itemIndex); + reward.requiredRank = static_cast(rank); + + // A reward with no claim flag carries slot 0, any other slot must map to the account bank. + if (claimSlot != 0) { + reward.claimFlagIndex = + bank_index(storage.slotMaps.accountFlag, static_cast(claimSlot)); + if (reward.claimFlagIndex == domain::kUnavailableFlagIndex) { + return false; + } + } + + std::size_t socketCount = 0; + std::size_t conditionCount = 0; + if (!read_reward_sockets(progressionTable, + at + tables::kProgressionRewardSocketsOffset, + reward.sockets, + socketCount) + || !storage.rewardBuild.conditions.read_list( + progressionTable, + at + tables::kProgressionRewardConditionsOffset, + reward.condition, + conditionCount, + failure)) { + return false; + } + reward.socketCount = static_cast(socketCount); + reward.conditionCount = static_cast(conditionCount); + // Checked per row so one refused row stays unavailable. + return state::build_data::valid_season_pass_rewards(std::span(&reward, 1)) + && std::all_of(reward.sockets.begin(), + reward.sockets.begin() + reward.socketCount, + [&itemRows](const state::build_data::rewards::SocketOverride& socket) { + return state::build_data::rewards::valid_socket(socket, + itemRows.count); + }); +} + } // namespace /** - * Reads the season pass reward list and the wrapper items it grants. - * A reward names an item index and a claim flag slot; both become the values a grant needs. + * Reads the season pass reward list in native order, which the opcode-2400 claim indexes. + * A row that fails to read is kept as an unavailable row so later claim indices stay aligned. * @param source Installed package source. - * @param storage Pass storage receiving the reward rows and the wrapper packages. + * @param storage Pass storage receiving the reward rows. * @param root Investment root bytes. * @return True when the reward list read and produced at least one row. */ @@ -97,55 +163,47 @@ bool build_season_pass(const reader::Source& source, tables::kTableArrayDescriptor, progressions) || progressions.elementClass != tables::kProgressionTableClass - || progressions.count <= kPassProgressionIndex) { + || progressions.count <= state::progression::season_pass::kProgressionDefinitionIndex) { return false; } const std::span progressionTable{storage.progressionTable}; - const std::size_t passAt = - progressions.dataOffset + kPassProgressionIndex * tables::kProgressionRowStride; + const std::size_t passAt = progressions.dataOffset + + state::progression::season_pass::kProgressionDefinitionIndex + * tables::kProgressionRowStride; tables::Array rewards{}; - if (!tables::find_optional_array_at( - progressionTable, passAt + tables::kProgressionRewardField, rewards) - || rewards.count == 0 || rewards.elementClass != tables::kProgressionRewardRowClass - || rewards.count > domain::kRewardCapacity - || rewards.dataOffset - + static_cast(rewards.count) * tables::kProgressionRewardStride - > progressionTable.size()) { + if (!tables::read_array(progressionTable, + passAt + tables::kProgressionRewardField, + tables::kProgressionRewardRowClass, + tables::kProgressionRewardStride, + rewards) + || rewards.count == 0 || rewards.count > domain::kRewardCapacity) { return false; } + std::size_t skipped = 0; for (std::uint64_t row = 0; row < rewards.count; ++row) { const std::size_t at = rewards.dataOffset + static_cast(row) * tables::kProgressionRewardStride; - std::uint32_t rank = 0; - std::uint32_t itemIndex = 0; - std::uint32_t claimSlot = 0; - domain::Reward& reward = storage.seasonPassRewards[storage.seasonPassRewardCount]; + auto& reward = storage.seasonPassRewards[row]; reward = {}; + auto failure = BankFailure::none; + if (!read_season_reward(storage, progressionTable, itemRows, at, reward, failure)) { + // A row is left unavailable only for its data, never for failed storage. + if (failure == BankFailure::storage) { + core::log::write(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=season_pass result=fail reason=condition_storage"); + return false; + } + reward = {}; + ++skipped; + continue; + } + tables::IndexRow entry{}; - if (!read(progressionTable, at + tables::kProgressionRewardRankOffset, rank) - || !read(progressionTable, at + tables::kProgressionRewardItemIndexOffset, itemIndex) - || !read( - progressionTable, at + tables::kProgressionRewardQuantityOffset, reward.quantity) - || !read(progressionTable, at + tables::kProgressionRewardClaimSlotOffset, claimSlot) - || rank > (std::numeric_limits::max)() - || itemIndex > (std::numeric_limits::max)() - || claimSlot > (std::numeric_limits::max)() - || !tables::index_row(itemTable, itemRows, itemIndex, entry)) { - storage.seasonPassRewardCount = 0; + if (!tables::index_row(itemTable, itemRows, reward.itemIndex, entry)) { return false; } - reward.itemHash = entry.definitionHash; - reward.itemIndex = static_cast(itemIndex); - reward.requiredRank = static_cast(rank); - // A reward with no claim flag carries slot 0. - if (claimSlot != 0) { - reward.claimFlagIndex = - bank_index(storage.slotMaps.accountFlag, static_cast(claimSlot)); - } - - // A wrapper reward opens into a set; a plain reward declares none and keeps zero items. - // One wrapper can be granted at several ranks, so it is recorded once. const std::span held = std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount); domain::Package package{}; @@ -163,8 +221,14 @@ bool build_season_pass(const reader::Source& source, && package.itemCount != 0) { storage.seasonPassPackages[storage.seasonPassPackageCount++] = package; } - ++storage.seasonPassRewardCount; } + if (skipped != 0) { + core::log::writef(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=season_pass skipped=%zu", + skipped); + } + storage.seasonPassRewardCount = static_cast(rewards.count); return storage.seasonPassRewardCount != 0; } diff --git a/Sunrise/src/client/content/vendors/layout.h b/Sunrise/src/client/content/vendors/layout.h index 8078dcb6d..44aced707 100644 --- a/Sunrise/src/client/content/vendors/layout.h +++ b/Sunrise/src/client/content/vendors/layout.h @@ -7,6 +7,20 @@ namespace sunrise::client::content::vendors { /** Tag of the installed vendor index blob, which names every vendor definition. */ inline constexpr std::uint32_t kIndexRootTag = 0x8131931DU; +/** Installed faction table named by the build-86657 investment content. */ +inline constexpr std::uint32_t kFactionTableTag = 0x81327CD5U; +/** The faction table's rows begin at this array descriptor. */ +inline constexpr std::size_t kFactionArrayDescriptor = 8; +/** One faction row holds its hash and progression index in the first eight bytes. */ +inline constexpr std::size_t kFactionRowStride = 24; +/** The installed faction table's row class. */ +inline constexpr std::uint32_t kFactionRowClass = 0x808074CEU; +/** Faction definition hash at the start of a faction row. */ +inline constexpr std::size_t kFactionHashOffset = 0; +/** Progression definition index following the faction hash. */ +inline constexpr std::size_t kFactionProgressionIndexOffset = 4; +/** Vendor definition +18 selects a faction row; out-of-range values name no faction. */ +inline constexpr std::size_t kVendorFactionIndexOffset = 18; /** A vendor definition holds its installed array descriptor here. */ inline constexpr std::size_t kInstalledArrayDescriptor = 32; @@ -14,6 +28,18 @@ inline constexpr std::size_t kInstalledArrayDescriptor = 32; inline constexpr std::size_t kSaleArrayDescriptor = 48; /** A vendor definition holds its unnamed third array descriptor here. */ inline constexpr std::size_t kThirdArrayDescriptor = 80; +/** Element class of an interaction row in the third vendor array. */ +inline constexpr std::uint32_t kInteractionRowClass = 0x80807857U; +/** Interaction row +8 holds its direct availability expression. */ +inline constexpr std::size_t kInteractionConditionField = 8; +/** Interaction row +56 names the vendor category it presents. */ +inline constexpr std::size_t kInteractionCategoryOffset = 56; +/** Interaction row +40 holds the reply array. */ +inline constexpr std::size_t kInteractionReplyField = 40; +/** Each reply is 24 bytes, beginning with its conditional reward program. */ +inline constexpr std::size_t kReplyRowStride = 24; +/** Element class of the installed interaction reply array. */ +inline constexpr std::uint32_t kReplyRowClass = 0x8080785BU; /** Raw reset interval. Its unit, epoch and scope are open, so it is stored unconverted. */ inline constexpr std::size_t kResetIntervalOffset = 20; /** Raw reset phase, paired with the interval. */ @@ -21,6 +47,8 @@ inline constexpr std::size_t kResetPhaseOffset = 24; /** Sale row cost array descriptor, which is what the row charges. */ inline constexpr std::size_t kSaleCostArrayDescriptor = 32; +/** Sale row +8 carries admission programs; +120 carries selection programs. */ +inline constexpr std::size_t kSaleAdmissionField = 8, kSaleSelectionField = 120; /** Cost entry item-definition index. */ inline constexpr std::size_t kSaleCostItemIndexOffset = 0; /** Cost entry static quantity. */ diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp index ecdd06a5a..2b2829e34 100644 --- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp +++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp @@ -20,9 +20,18 @@ namespace reader = middleware::content::packages::reader; namespace tables = middleware::content::packages::tables; namespace domain = state::build_data::vendors; +/** One array a definition or a sale row declares, reduced to what the catalog stores. */ +struct ArrayView { + std::uint32_t base{}; + std::uint32_t classId{}; + std::uint16_t count{}; +}; + /** Every extracted row, kept off the caller stack. */ struct Storage { std::vector blob{}; + std::vector factionBlob{}; + ArrayView factionRows{}; std::array index{}; std::array definitions{}; std::array saleRows{}; @@ -39,6 +48,8 @@ struct Storage { /** Only published prefixes are visible; extraction fully assigns each new row. */ void reset() noexcept { blob.clear(); + factionBlob.clear(); + factionRows = {}; indexCount = 0; definitionCount = 0; saleRowCount = 0; @@ -48,13 +59,6 @@ struct Storage { } }; -/** One array a definition or a sale row declares, reduced to what the catalog stores. */ -struct ArrayView { - std::uint32_t base{}; - std::uint32_t classId{}; - std::uint16_t count{}; -}; - /** @param blob Source bytes. @param offset Field offset. @param value Receives the field. */ template [[nodiscard]] bool @@ -103,6 +107,17 @@ read(std::span blob, std::size_t offset, Value& value) noexcept return true; } +/** Reads the native faction rows used by vendor definition +18. */ +[[nodiscard]] bool +read_factions(const reader::Source& source, reader::Scratch& scratch, Storage& storage) noexcept { + return reader::read_tag(source, scratch, kFactionTableTag, storage.factionBlob) + && read_array(std::span{storage.factionBlob}, + kFactionArrayDescriptor, + kFactionRowStride, + storage.factionRows) + && storage.factionRows.count != 0 && storage.factionRows.classId == kFactionRowClass; +} + /** * Reads one sale row's cost entries and whether they are its price. * An entry is static only with both expression arrays empty and the plain trailing word. Any other @@ -290,6 +305,26 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor definition.thirdRowBase = third.base; definition.thirdRowClass = third.classId; definition.thirdCount = third.count; + if (!read(blob, kVendorFactionIndexOffset, definition.factionIndexRaw)) { + return false; + } + if (definition.factionIndexRaw < storage.factionRows.count) { + const std::size_t at = + storage.factionRows.base + + static_cast(definition.factionIndexRaw) * kFactionRowStride; + std::uint32_t progressionIndex = 0; + if (!read(std::span{storage.factionBlob}, + at + kFactionHashOffset, + definition.factionHash) + || !read(std::span{storage.factionBlob}, + at + kFactionProgressionIndexOffset, + progressionIndex) + || definition.factionHash == 0 + || progressionIndex > (std::numeric_limits::max)()) { + return false; + } + definition.factionProgressionIndex = static_cast(progressionIndex); + } definition.saleRowOffset = static_cast(storage.saleRowCount); definition.installedRowOffset = static_cast(storage.installedRowCount); // A skipped definition must leave both banks exactly as it found them; an orphan sale row @@ -347,6 +382,13 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { } static Storage storage{}; storage.reset(); + if (!read_factions(source, scratch, storage)) { + // A missing faction table removes reputation links, not ordinary vendor sales. + storage.factionRows = {}; + core::log::write(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendors result=factions_unavailable"); + } if (!read_index(source, scratch, storage)) { report(storage, 0, "index"); return false; diff --git a/Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp new file mode 100644 index 000000000..7028c838f --- /dev/null +++ b/Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp @@ -0,0 +1,352 @@ +#include +#include +#include +#include +#include + +#include "../../../core/logging/log.h" +#include "../../../middleware/content/packages/tables/definition_index_table.h" +#include "../../../middleware/content/packages/tables/field_reader.h" +#include "../../../state/build_data/rewards/reward_catalog.h" +#include "../../../state/build_data/runtime.h" +#include "../../../state/build_data/vendors/vendor_catalog.h" +#include "../../../state/build_data/vendors/vendor_gate_catalog.h" +#include "layout.h" +#include "vendor_build.h" + +namespace sunrise::client::content::vendors { +namespace { + +namespace reader = middleware::content::packages::reader; +namespace tables = middleware::content::packages::tables; +namespace domain = state::build_data::vendors; +namespace unlocks = state::unlocks; + +/** The native slot maps mark unmapped positions with the all-ones row value. */ +constexpr std::uint16_t kUnmappedRow = 0xFFFFU; +/** Native class of an eight-byte unlock instruction in an interaction or sale gate. */ +constexpr std::uint32_t kInstructionClass = 0x80807D31U; +/** Native class of a sale gate list's sixteen-byte expression descriptors. */ +constexpr std::uint32_t kProgramListClass = 0x80807D2FU; + +/** One unbound opcode and native slot or literal read from a vendor row. */ +struct NativeInstruction { + unlocks::Opcode opcode{}; + std::int32_t operand{}; +}; + +/** Native instruction storage before unlock slots are mapped to saved banks. */ +struct NativeProgram { + std::array instructions{}; + std::size_t count{}; +}; + +/** + * Appends one native instruction array, refusing unknown opcodes and capacity overflow. + * @param blob Vendor definition bytes. + * @param field Array descriptor offset. + * @param program Accumulates the decoded instructions. + * @return False when the array cannot be decoded within the fixed capacity. + */ +[[nodiscard]] bool append_program(std::span blob, + std::size_t field, + NativeProgram& program) noexcept { + tables::Array rows{}; + if (!tables::read_array(blob, field, kInstructionClass, tables::kUnlockInstructionStride, rows) + || rows.count > program.instructions.size() - program.count) { + return false; + } + for (std::size_t index = 0; index < rows.count; ++index) { + const auto at = rows.dataOffset + index * tables::kUnlockInstructionStride; + std::uint32_t nativeOpcode = 0; + std::int32_t operand = 0; + unlocks::Opcode opcode{}; + if (!tables::read(blob, at, nativeOpcode) + || !tables::read(blob, at + tables::kUnlockInstructionOperandOffset, operand) + || !unlocks::decode_opcode(nativeOpcode, opcode)) { + return false; + } + program.instructions[program.count++] = {opcode, operand}; + } + return true; +} + +/** Reads one interaction's direct native instruction array. */ +[[nodiscard]] bool +read_program(std::span blob, std::size_t field, NativeProgram& output) noexcept { + output = {}; + return append_program(blob, field, output); +} + +/** + * Joins a sale's authored program list with postfix AND. + * @param blob Vendor definition bytes. + * @param field Program-list descriptor offset. + * @param output Receives the complete program, or remains empty on failure. + * @return False when a listed program is empty, malformed or over capacity. + */ +[[nodiscard]] bool read_program_list(std::span blob, + std::size_t field, + NativeProgram& output) noexcept { + output = {}; + tables::Array rows{}; + if (!tables::read_array( + blob, field, kProgramListClass, tables::kUnlockExpressionFieldSize, rows)) { + return false; + } + NativeProgram parsed{}; + for (std::size_t index = 0; index < rows.count; ++index) { + const auto at = rows.dataOffset + index * tables::kUnlockExpressionFieldSize; + const auto before = parsed.count; + if (!append_program(blob, at, parsed) || parsed.count == before) { + return false; + } + if (index != 0) { + if (parsed.count == parsed.instructions.size()) { + return false; + } + parsed.instructions[parsed.count++] = {unlocks::Opcode::logicalAnd, 0}; + } + } + output = parsed; + return true; +} + +/** Binds one input to at most one saved bank; unmapped slots require Family-5 values. */ +[[nodiscard]] bool bind_input(const NativeInstruction& instruction, + const GateMaps& maps, + domain::GateInput& output) noexcept { + output = {}; + const auto slot = instruction.operand; + if (slot < 0 || static_cast(slot) >= maps.accountFlag.size() + || static_cast(slot) >= maps.profileFlag.size() + || static_cast(slot) >= maps.characterFlag.size() + || static_cast(slot) >= maps.accountValue.size() + || static_cast(slot) >= maps.characterValue.size()) { + return false; + } + output.opcode = instruction.opcode; + output.slot = static_cast(slot); + output.bank = domain::GateBank::external; + const auto select = [&](std::span map, domain::GateBank bank) { + if (map[slot] == kUnmappedRow) { + return true; + } + if (output.bank != domain::GateBank::external) { + return false; + } + output.bank = bank; + output.row = map[slot]; + return true; + }; + if (instruction.opcode == domain::Opcode::flag) { + return select(maps.accountFlag, domain::GateBank::accountFlag) + && select(maps.profileFlag, domain::GateBank::profileFlag) + && select(maps.characterFlag, domain::GateBank::characterFlag); + } + return select(maps.accountValue, domain::GateBank::accountValue) + && select(maps.characterValue, domain::GateBank::characterValue); +} + +/** + * Retains native slots for callback lookup and maps only inputs the expression reads. + * @param program Decoded native instructions. + * @param maps Installed unlock-slot mappings. + * @param gate Receives the bound program and inputs. + * @return False when an input is ambiguous, out of range or over capacity. + */ +[[nodiscard]] bool +bind_gate(const NativeProgram& program, const GateMaps& maps, domain::Gate& gate) noexcept { + gate = {}; + gate.program.count = program.count; + for (std::size_t row = 0; row < program.count; ++row) { + const auto& instruction = program.instructions[row]; + const bool readsSlot = instruction.opcode == domain::Opcode::flag + || instruction.opcode == domain::Opcode::loadValue; + auto& bound = gate.program.instructions[row]; + // Keep the native slot so a Family-5 override can precede its saved-bank mapping. + bound = {instruction.opcode, + readsSlot ? unlocks::Bank::external : unlocks::Bank::none, + static_cast(instruction.operand)}; + if (!unlocks::valid(bound)) { + return false; + } + if (!readsSlot) { + continue; + } + const auto inputs = std::span{gate.inputs}.first(gate.inputCount); + const auto found = std::find_if(inputs.begin(), inputs.end(), [&](const auto& input) { + return input.opcode == instruction.opcode && input.slot == instruction.operand; + }); + if (found != inputs.end()) { + continue; + } + if (gate.inputCount == gate.inputs.size() + || !bind_input(instruction, maps, gate.inputs[gate.inputCount])) { + return false; + } + ++gate.inputCount; + } + return true; +} + +/** A free sale is a candidate only when its installed wrapper opens a reward pool. */ +[[nodiscard]] bool reward_package(const domain::SaleRow& sale) noexcept { + state::build_data::items::Definition item{}; + state::build_data::rewards::Item reward{}; + return sale.categoryIndex != domain::kAbsentCategoryIndex + && sale.priceState == domain::PriceState::plain && sale.costCount == 0 + && state::build_data::find_item_definition_index(sale.itemIndex, item) + && state::build_data::find_reward_item(sale.itemIndex, reward) + && item.definitionHash == reward.definitionHash + && reward.poolIndex != state::build_data::rewards::kAbsent + && (reward.flags & state::build_data::rewards::kOpenOnAcquisition) != 0; +} + +/** + * Appends one vendor's package-sale, interaction and reply gates only after complete decoding. + * @param source Borrowed package source; no package spans escape this call. + * @param scratch Reusable package-reader storage. + * @param maps Borrowed installed unlock-slot mappings. + * @param vendor Published vendor identity and expected array bounds. + * @param interactions Receives copied interaction and reply gates on success. + * @param sales Receives copied sale gates on success. + * @return False for unreadable or unsupported data; neither output is appended on failure. + */ +[[nodiscard]] bool read_vendor(const reader::Source& source, + reader::Scratch& scratch, + const GateMaps& maps, + const domain::Definition& vendor, + std::vector& interactions, + std::vector& sales) noexcept { + std::vector candidates; + for (std::size_t row = 0; row < vendor.saleCount; ++row) { + domain::SaleRow sale{}; + if (domain::sale_row(vendor, row, sale) && reward_package(sale)) { + candidates.push_back(static_cast(row)); + } + } + if (candidates.empty()) { + return true; + } + std::vector blob; + std::uint32_t classId = 0; + if (!reader::read_tag(source, scratch, vendor.definitionTag, blob, classId) + || classId != domain::kDefinitionClass || blob.size() != vendor.definitionSize) { + return false; + } + const std::span bytes{blob}; + tables::Array saleRows{}, interactionRows{}; + if (!tables::read_array( + bytes, kSaleArrayDescriptor, domain::kSaleRowClass, domain::kSaleRowStride, saleRows) + || saleRows.count != vendor.saleCount || saleRows.dataOffset != vendor.saleRowBase + || !tables::read_array(bytes, + kThirdArrayDescriptor, + kInteractionRowClass, + domain::kThirdRowStride, + interactionRows) + || interactionRows.count != vendor.thirdCount + || interactionRows.dataOffset != vendor.thirdRowBase) { + return false; + } + std::vector ownSales; + for (const auto row : candidates) { + const auto at = saleRows.dataOffset + row * domain::kSaleRowStride; + domain::SaleRow sale{}; + NativeProgram admission{}, selection{}; + domain::SaleGates gates{}; + if (!domain::sale_row(vendor, row, sale) + || !read_program_list(bytes, at + kSaleAdmissionField, admission) + || !read_program_list(bytes, at + kSaleSelectionField, selection) + || !bind_gate(admission, maps, gates.admission) + || !bind_gate(selection, maps, gates.selection)) { + return false; + } + gates.vendorHash = vendor.definitionHash; + gates.index = row; + gates.categoryIndex = sale.categoryIndex; + ownSales.push_back(gates); + } + std::vector ownInteractions; + for (std::size_t row = 0; row < interactionRows.count; ++row) { + const auto at = interactionRows.dataOffset + row * domain::kThirdRowStride; + std::int32_t category = 0; + if (!tables::read(bytes, at + kInteractionCategoryOffset, category)) { + return false; + } + if (std::none_of(ownSales.begin(), ownSales.end(), [&](const auto& sale) { + return sale.categoryIndex == category; + })) { + continue; + } + NativeProgram condition{}; + domain::InteractionGate gate{}; + tables::Array replies{}; + if (!read_program(bytes, at + kInteractionConditionField, condition) + || !bind_gate(condition, maps, gate.condition) + || !tables::read_array( + bytes, at + kInteractionReplyField, kReplyRowClass, kReplyRowStride, replies)) { + return false; + } + for (std::size_t reply = 0; reply < replies.count; ++reply) { + NativeProgram program{}; + domain::Gate replyCondition{}; + if (!read_program(bytes, replies.dataOffset + reply * kReplyRowStride, program) + || !bind_gate(program, maps, replyCondition)) { + return false; + } + gate.replyConditions.push_back(replyCondition); + } + gate.vendorHash = vendor.definitionHash; + gate.index = static_cast(row); + gate.categoryIndex = category; + ownInteractions.push_back(gate); + } + sales.insert(sales.end(), ownSales.begin(), ownSales.end()); + interactions.insert(interactions.end(), ownInteractions.begin(), ownInteractions.end()); + return true; +} + +} // namespace + +/** Reads only faction vendors with free installed reward wrappers. */ +bool build_gates(const reader::Source& source, + reader::Scratch& scratch, + const GateMaps& maps) noexcept { + if (domain::gates_ready()) { + return true; + } + static std::array definitions{}; + std::size_t count = 0; + if (!domain::snapshot_definitions(definitions, count)) { + return false; + } + std::vector interactions; + std::vector sales; + std::size_t skipped = 0; + for (std::size_t row = 0; row < count; ++row) { + const auto& vendor = definitions[row]; + if (vendor.factionProgressionIndex == domain::kUnavailableFactionProgressionIndex) { + continue; + } + if (!read_vendor(source, scratch, maps, vendor, interactions, sales)) { + ++skipped; + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendor_gates result=skip hash=0x%08X", + vendor.definitionHash); + } + } + const bool published = domain::replace_gates(interactions, sales); + core::log::writef( + core::log::Channel::state, + published ? core::log::Level::info : core::log::Level::warn, + "ev=build_data stage=vendor_gates result=%s interactions=%zu sales=%zu skipped=%zu", + published ? "ok" : "fail", + interactions.size(), + sales.size(), + skipped); + return published; +} + +} // namespace sunrise::client::content::vendors diff --git a/Sunrise/src/client/content/vendors/vendor_build.h b/Sunrise/src/client/content/vendors/vendor_build.h index 5d01ea917..4e172da60 100644 --- a/Sunrise/src/client/content/vendors/vendor_build.h +++ b/Sunrise/src/client/content/vendors/vendor_build.h @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include "../../../middleware/content/packages/reader/reader.h" namespace sunrise::client::content::vendors { @@ -14,4 +17,24 @@ namespace sunrise::client::content::vendors { [[nodiscard]] bool build(const middleware::content::packages::reader::Source& source, middleware::content::packages::reader::Scratch& scratch) noexcept; +/** Installed unlock-slot maps used to bind native vendor expression operands to saved rows. */ +struct GateMaps { + std::span accountFlag; + std::span profileFlag; + std::span characterFlag; + std::span accountValue; + std::span characterValue; +}; + +/** + * Extracts faction-package gates each process, including when vendor rows came from cache. + * @param source Installed package directory and borrowed keys. + * @param scratch Caller-owned package reader storage. + * @param maps Native unlock-slot mappings retained from the investment root. + * @return True after the complete derived gate catalog is published. + */ +[[nodiscard]] bool build_gates(const middleware::content::packages::reader::Source& source, + middleware::content::packages::reader::Scratch& scratch, + const GateMaps& maps) noexcept; + } // namespace sunrise::client::content::vendors diff --git a/Sunrise/src/middleware/content/packages/tables/activity_table.h b/Sunrise/src/middleware/content/packages/tables/activity_table.h index c10becf27..8613c76b5 100644 --- a/Sunrise/src/middleware/content/packages/tables/activity_table.h +++ b/Sunrise/src/middleware/content/packages/tables/activity_table.h @@ -4,6 +4,7 @@ #include "../../../../state/build_data/activities/activity_catalog.h" #include "definition_index_table.h" +#include "field_reader.h" namespace sunrise::middleware::content::packages::tables::activities { /** Public activity index, NOT the package-definition index in root slot 29. */ @@ -32,26 +33,6 @@ template std::memcpy(&value, bytes.data() + offset, sizeof(T)); return true; } -/** - * Resolves a self-relative field. - * @param field Offset of the 8-byte signed delta. - * @param target Receives the resolved offset. - * @return False when the delta is zero or the target falls outside the blob. - */ -[[nodiscard]] inline bool -relative(std::span bytes, std::size_t field, std::size_t& target) noexcept { - std::int64_t delta{}; - if (!read(bytes, field, delta) || delta == 0 - || field > static_cast((std::numeric_limits::max)())) { - return false; - } - const auto base = static_cast(field); - if (delta < -base || delta > (std::numeric_limits::max)() - base) { - return false; - } - target = static_cast(base + delta); - return target < bytes.size(); -} /** * Parses the dense public activity table. * @param bytes The table blob. diff --git a/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp b/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp index 808c396d5..1bcbfe584 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp @@ -126,6 +126,18 @@ bool find_optional_array_at(std::span blob, return resolve_descriptor(blob, descriptorOffset, true, output); } +/** Reads an optional typed array and checks every fixed-stride row lies in its blob. */ +bool read_array(std::span blob, + std::size_t at, + std::uint32_t elementClass, + std::size_t stride, + Array& rows) noexcept { + return stride != 0 && find_optional_array_at(blob, at, rows) + && (rows.count == 0 + || (rows.elementClass == elementClass && rows.dataOffset <= blob.size() + && rows.count <= (blob.size() - rows.dataOffset) / stride)); +} + /** Finds the first array whose header names one element class. */ bool find_array(std::span blob, std::uint32_t elementClass, diff --git a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h index 5d0eec5df..268589974 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h @@ -30,6 +30,8 @@ inline constexpr std::uint16_t kAbsentPackageId = 0xFFFFU; /** Element class of the item index table inside the investment container. */ inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U; +/** Serialized width of one item-index row; byte zero carries the definition hash. */ +inline constexpr std::size_t kItemIndexRowStride = 24; /** Serialized item definition class, distinct from the item index table class. */ inline constexpr std::uint32_t kItemDefinitionClass = 0x80807BEAU; /** Investment root slot of the records and lore table. */ @@ -103,6 +105,8 @@ inline constexpr std::uint32_t kUnlockReadValueOpcode = 10; inline constexpr std::uint32_t kUnlockReadFlagOpcode = 1; /** The opcode that pushes a literal. */ inline constexpr std::uint32_t kUnlockLiteralOpcode = 11; +/** The opcode that expands a shared expression row. Its operand is the row index. */ +inline constexpr std::uint32_t kUnlockExpressionOpcode = 12; /** The opcode that tests greater than or equal. */ inline constexpr std::uint32_t kUnlockGreaterEqualOpcode = 14; /** High half every installed array marker and element class carries. */ @@ -123,6 +127,11 @@ inline constexpr std::size_t kNodeExpressionFieldAlternate = 48; /** Records a node owns, four bytes each as a row then a gate. */ inline constexpr std::size_t kNodeChildRecordField = 136; inline constexpr std::size_t kNodeChildRecordStride = 4; +/** + * Array descriptor of the profile flag mapping table, between the account (+8) and character (+40) + * maps. Profile flags are account-wide but saved in their own bank, not the account flag bank. + */ +inline constexpr std::size_t kProfileFlagMapDescriptor = 24; /** Array descriptor of the character object's flag mapping table, sized to that bank. */ inline constexpr std::size_t kCharacterFlagMapDescriptor = 40; /** Array descriptor of the character object's value mapping table, sized to that bank. */ @@ -202,6 +211,8 @@ inline constexpr std::uint32_t kProgressionTableClass = 0x80807CDDU; inline constexpr std::size_t kProgressionRowStride = 88; /** A progression definition names the object array holding its record here. */ inline constexpr std::size_t kProgressionScopeOffset = 4; +/** A progression definition repeats its final step when this byte is one. */ +inline constexpr std::size_t kProgressionRepeatLastStepOffset = 5; /** Rank steps a progression declares: the experience each rank costs, in rank order. */ inline constexpr std::size_t kProgressionStepField = 24; inline constexpr std::uint32_t kProgressionStepRowClass = 0x80807CEDU; @@ -216,9 +227,14 @@ inline constexpr std::size_t kProgressionRewardItemIndexOffset = 4; inline constexpr std::size_t kProgressionRewardQuantityOffset = 8; /** Unlock flag slot a reward's claim sets. The slots run dense in reward order. */ inline constexpr std::size_t kProgressionRewardClaimSlotOffset = 20; -/** Items a wrapper item opens into, two bytes each as an item-definition index. */ +/** Eligibility expressions and socket overrides carried by one progression reward. */ +inline constexpr std::size_t kProgressionRewardConditionsOffset = 24; +inline constexpr std::size_t kProgressionRewardSocketsOffset = 40; +/** Item-definition field containing the wrapper's gear-set array. */ inline constexpr std::size_t kGearsetItemField = 392; +/** Element class of one gear-set item-index row. */ inline constexpr std::uint32_t kGearsetItemRowClass = 0x808087DBU; +/** Serialized width of one gear-set item index. */ inline constexpr std::size_t kGearsetItemStride = 2; /** Expression that makes one collectible acquired, which a purchase or a grant sets. */ @@ -277,6 +293,13 @@ using RowVisitor = bool (*)(void* context, std::uint32_t index, const IndexRow& std::size_t descriptorOffset, Array& output) noexcept; +/** Reads an optional typed array and checks every fixed-stride row lies in its blob. */ +[[nodiscard]] bool read_array(std::span blob, + std::size_t at, + std::uint32_t elementClass, + std::size_t stride, + Array& rows) noexcept; + /** * Finds the first array whose header names one element class. * @param blob Whole definition bytes. diff --git a/Sunrise/src/middleware/content/packages/tables/field_reader.h b/Sunrise/src/middleware/content/packages/tables/field_reader.h new file mode 100644 index 000000000..55ed6d27e --- /dev/null +++ b/Sunrise/src/middleware/content/packages/tables/field_reader.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace sunrise::middleware::content::packages::tables { +/** + * Reads one little-endian field that must lie inside the blob. + * @param blob Whole definition bytes. + * @param offset Field offset. + * @param value Receives the field. + * @return True when the whole field is inside the blob. + */ +template +[[nodiscard]] inline bool +read(std::span blob, std::size_t offset, Value& value) noexcept { + // Subtracting rather than adding keeps a large offset from wrapping past the size. + if (offset > blob.size() || blob.size() - offset < sizeof value) { + return false; + } + std::memcpy(&value, blob.data() + offset, sizeof value); + return true; +} + +/** + * Resolves a self-relative field. + * @param field Offset of the 8-byte signed delta. + * @param target Receives the resolved offset. + * @return False when the delta is zero or the target falls outside the blob. + */ +[[nodiscard]] inline bool +relative(std::span bytes, std::size_t field, std::size_t& target) noexcept { + std::int64_t delta{}; + if (!read(bytes, field, delta) || delta == 0 + || field > static_cast((std::numeric_limits::max)())) { + return false; + } + const auto base = static_cast(field); + if (delta < -base || delta > (std::numeric_limits::max)() - base) { + return false; + } + target = static_cast(base + delta); + return target < bytes.size(); +} + +} // namespace sunrise::middleware::content::packages::tables diff --git a/Sunrise/src/middleware/content/packages/tables/internal.h b/Sunrise/src/middleware/content/packages/tables/internal.h index 393ac00d0..2384f4996 100644 --- a/Sunrise/src/middleware/content/packages/tables/internal.h +++ b/Sunrise/src/middleware/content/packages/tables/internal.h @@ -2,28 +2,11 @@ #include #include -#include #include -namespace sunrise::middleware::content::packages::tables { +#include "field_reader.h" -/** - * Reads one little-endian field that must lie inside the blob. - * @param blob Whole definition bytes. - * @param offset Field offset. - * @param value Receives the field. - * @return True when the whole field is inside the blob. - */ -template -[[nodiscard]] inline bool -read(std::span blob, std::size_t offset, Value& value) noexcept { - // Subtracting rather than adding keeps a large offset from wrapping past the size. - if (offset > blob.size() || blob.size() - offset < sizeof value) { - return false; - } - std::memcpy(&value, blob.data() + offset, sizeof value); - return true; -} +namespace sunrise::middleware::content::packages::tables { /** * Finds the offset of one fixed-stride array element. diff --git a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp index 7bdc38282..69953c965 100644 --- a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp +++ b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp @@ -1,6 +1,6 @@ /** - * Opcode 904 acquires a quest or other pursuit from a vendor. - * Three 16-bit fields biased by 0x8000 (vendor, clicked slot, one always zero), then a 32-bit + * Opcode 904 carries vendor acquisitions and interaction replies, including rank claims. + * Three 16-bit selectors biased by 0x8000, then a 32-bit * field biased by 0x80000000 naming the sale row, then one trailing byte that is skipped. */ @@ -23,7 +23,7 @@ constexpr std::size_t kTrailingLimit = 8; } // namespace -/** Decodes one quest-acquire request body. */ +/** Decodes one vendor acquisition or interaction request body. */ bool parse_request(const Message& message, Request& output) noexcept { if (message.opcode != kOpcode) { return false; diff --git a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h index 0e8df1f54..7fd412d3c 100644 --- a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h +++ b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h @@ -6,16 +6,18 @@ namespace sunrise::middleware::web_service::messages::opcode904 { -/** Web Service opcode for acquiring a quest or other pursuit from a vendor. */ +/** Web Service opcode for vendor acquisitions and interaction replies, including rank claims. */ inline constexpr std::uint16_t kOpcode = 904; +/** Logical -1 selects an interaction without a sale row. */ +inline constexpr std::int32_t kAbsentSaleIndex = -1; -/** One decoded quest-acquire request: three biased 16-bit fields then one biased 32-bit row. */ +/** One vendor request: three biased 16-bit selectors followed by a biased 32-bit sale row. */ struct Request { /** Index into the vendor table, the same table 901 indexes. */ std::int16_t vendorIndex{}; /** UI slot the click landed on. Not a sale row. */ std::int16_t slotIndex{}; - /** Always zero on the wire; meaning unknown. */ + /** Reply index for rowless interactions; sale-backed semantics are unresolved. */ std::int16_t third{}; /** * Sale row of the vendor definition, 32-bit biased by 0x80000000. @@ -27,8 +29,7 @@ struct Request { }; /** - * Decodes one quest-acquire request body. - * The four fields account for ten of the eleven body bytes; the trailing byte is skipped. + * Decodes three required selectors and an optional sale row, allowing one trailing byte. * @param message Parsed Web Service envelope. * @param output Receives the request only when the three leading fields decode. * @return True when the opcode matches and those fields are present. diff --git a/Sunrise/src/server/bap/bap_world_reward_queue.cpp b/Sunrise/src/server/bap/bap_world_reward_queue.cpp index 6c27c8ea6..3cbd24b66 100644 --- a/Sunrise/src/server/bap/bap_world_reward_queue.cpp +++ b/Sunrise/src/server/bap/bap_world_reward_queue.cpp @@ -1,4 +1,10 @@ +#include +#include +#include +#include + #include "../../core/logging/log.h" +#include "../../middleware/crypto/random_bytes.h" #include "../../state/build_data/runtime.h" #include "../../state/investment/store_internal.h" #include "internal.h" @@ -6,25 +12,72 @@ namespace sunrise::server::bap { namespace { -/** Grants and retires one earned reward in the same database transaction. */ -bool commit_world_reward(const WorldRewardRequest& request) noexcept { +/** Logged for a saved row whose hash is not in the item table. */ +constexpr std::uint16_t kUnresolvedItemIndex = (std::numeric_limits::max)(); + +/** Outcome of one settlement attempt. */ +enum class WorldRewardSettlement : std::uint8_t { granted, retired, kept }; + +/** Grants and retires one earned reward in one transaction, or retires one State rules out. */ +WorldRewardSettlement commit_world_reward(const WorldRewardRequest& request) noexcept { state::investment::store::Transaction transaction; if (!transaction.ready()) { - return false; + return WorldRewardSettlement::kept; } bool committed = false; + bool unresolvable = false; + const char* reason = nullptr; if (request.kind == WorldRewardKind::item) { - state::PendingItemAcquisition acquisition; - committed = - state::prepare_item_acquisition_for_item(request.itemDefinitionIndex, acquisition) - && state::commit_item_acquisition(acquisition); + if (state::item_grant_route(request.itemDefinitionIndex) == state::ItemGrantRoute::quest) { + if (request.quantity != 1) { + unresolvable = true; + reason = "instance_quantity"; + } else { + state::PendingItemAcquisition acquisition; + committed = state::prepare_item_acquisition_for_item(request.itemDefinitionIndex, + acquisition) + && state::commit_item_acquisition(acquisition); + } + } else { + const std::unique_ptr grant( + new (std::nothrow) state::PendingRecordRewardGrant); + std::uint64_t seed = 0; + const bool seeded = + middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1))); + reason = seeded ? "reward_allocation" : "random_source"; + auto preparation = state::RewardPreparation::deferred; + if (seeded && grant) { + preparation = + state::prepare_item_reward(request.itemDefinitionIndex, + static_cast(request.quantity), + seed, + *grant, + &reason); + } + unresolvable = preparation == state::RewardPreparation::unresolvable; + committed = preparation == state::RewardPreparation::prepared; + if (committed) { + reason = "inventory_commit"; + committed = state::commit_record_reward(*grant); + } + if (!committed && !unresolvable) { + report_reward_refusal("world_settle", request.itemDefinitionIndex, reason); + } + } } else { state::PendingProfileItemAcquisition acquisition; committed = state::prepare_profile_item_acquisition_for_item( request.itemDefinitionIndex, request.quantity, acquisition) && state::commit_profile_item_acquisition(acquisition); } - return committed && complete_world_reward(request.id) && transaction.commit(); + if (unresolvable) { + return retire_world_reward(transaction, request, reason) ? WorldRewardSettlement::retired + : WorldRewardSettlement::kept; + } + if (!committed || !complete_world_reward(request.id) || !transaction.commit()) { + return WorldRewardSettlement::kept; + } + return WorldRewardSettlement::granted; } /** Saves the reward under its stable definition hash and earning character. */ @@ -45,6 +98,16 @@ bool enqueue_world_reward(std::uint16_t definitionIndex, } // namespace +/** Logs one refused reward stage with its native index and a specific reason. */ +void report_reward_refusal(const char* stage, std::uint16_t index, const char* reason) noexcept { + core::log::writef(core::log::Channel::server, + core::log::Level::warn, + "ev=reward stage=%s index=%u result=refused reason=%s", + stage, + static_cast(index), + reason != nullptr ? reason : "unknown"); +} + /** Saves one item reward before its pickup presentation is queued. */ bool arm_world_item_acquisition(std::uint16_t itemDefinitionIndex) noexcept { return enqueue_world_reward(itemDefinitionIndex, 1, WorldRewardKind::item); @@ -57,40 +120,82 @@ bool arm_world_profile_item_acquisition(std::uint16_t itemDefinitionIndex, && enqueue_world_reward(itemDefinitionIndex, quantity, WorldRewardKind::profileItem); } -/** Restores the oldest reward belonging to the selected character. */ +/** Restores the selected character's oldest reward, retiring rows that can never be granted. */ bool current_world_reward(WorldRewardRequest& request) noexcept { - request = {}; state::investment::store::PendingReward saved; - state::build_data::items::Definition definition; - if (!state::investment::store::next_reward(saved) - || !state::build_data::find_item_definition_hash(saved.definitionHash, definition)) { - return false; + while (state::investment::store::next_reward(saved)) { + request = {}; + request.id = saved.id; + request.definitionHash = saved.definitionHash; + request.quantity = saved.quantity; + request.kind = static_cast(saved.kind); + const bool itemsPublished = state::build_data::item_definitions_ready(); + state::build_data::items::Definition definition; + const char* verdict = "reward_quantity"; + if (state::build_data::find_item_definition_hash(saved.definitionHash, definition)) { + request.itemDefinitionIndex = definition.definitionIndex; + if (request.quantity > 0) { + return true; + } + } else if (!itemsPublished || !state::build_data::item_definitions_ready()) { + // An unpublished item table proves nothing; retry later. + break; + } else { + request.itemDefinitionIndex = kUnresolvedItemIndex; + verdict = "item_definition"; + } + state::investment::store::Transaction transaction; + if (!transaction.ready() || !retire_world_reward(transaction, request, verdict)) { + break; + } } - request.id = saved.id; - request.quantity = saved.quantity; - request.itemDefinitionIndex = definition.definitionIndex; - request.kind = static_cast(saved.kind); - return true; + request = {}; + return false; } -/** Acknowledges exactly the reward whose inventory grant is being committed. */ +/** Acknowledges exactly the reward being granted or retired. */ bool complete_world_reward(std::uint64_t id) noexcept { return state::investment::store::complete_reward(id); } -/** A failed grant stays saved for a later attempt. */ +/** Deletes the row on the caller's open savepoint, commits, and logs why. */ +bool retire_world_reward(state::investment::store::Transaction& transaction, + const WorldRewardRequest& request, + const char* reason) noexcept { + const bool retired = complete_world_reward(request.id) && transaction.commit(); + core::log::writef(core::log::Channel::server, + core::log::Level::warn, + "ev=reward stage=world_retire id=%llu hash=0x%08X index=%u quantity=%d " + "kind=%u result=%s reason=%s", + static_cast(request.id), + static_cast(request.definitionHash), + static_cast(request.itemDefinitionIndex), + static_cast(request.quantity), + static_cast(request.kind), + retired ? "retired" : "kept", + reason != nullptr ? reason : "unknown"); + return retired; +} + +/** A retired reward lets the next one take its turn; any other failed grant stays saved. */ void settle_world_reward() noexcept { WorldRewardRequest request; - if (current_world_reward(request) && commit_world_reward(request)) { - arm_account_resync_everywhere(); + while (current_world_reward(request)) { + const WorldRewardSettlement settlement = commit_world_reward(request); + if (settlement == WorldRewardSettlement::granted) { + arm_account_resync_everywhere(); + } + if (settlement != WorldRewardSettlement::retired) { + return; + } } } -/** Shutdown may grant ready rewards; rewards for other characters stay in the database. */ +/** Shutdown grants or retires rewards until one must wait; other characters' rewards stay saved. */ void drain_world_rewards() noexcept { WorldRewardRequest request; while (current_world_reward(request)) { - if (!commit_world_reward(request)) { + if (commit_world_reward(request) == WorldRewardSettlement::kept) { break; } } diff --git a/Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp b/Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp index cf76bbd84..1dcd97d71 100644 --- a/Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp +++ b/Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp @@ -380,10 +380,7 @@ void grant_random_moon_loot() noexcept { grant_lost_ghost_reward(); // One found ghost pays this much seasonal experience. constexpr std::int32_t kBaseExperienceReward = 2500; - const bool queued = bap::arm_seasonal_experience_presentation(kBaseExperienceReward); - if (!queued) { - (void)state::grant_seasonal_experience(kBaseExperienceReward); - } + (void)bap::arm_seasonal_experience_presentation(kBaseExperienceReward); } return outcome; } diff --git a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp index 9834c6c58..3e67cabe8 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -307,6 +307,26 @@ bool process(const ServiceRoute& route, return refuse_web_action(message, output, written); } } + const auto* reputation = + web_service::mutation_if(webOutcome); + if (reputation != nullptr) { + if (!queuezState.family4Active || queuezState.family4RootSoid != reputation->accountSoid + || queuezState.family4Version == (std::numeric_limits::max)() + || emplace_transaction(outcome, *reputation) + == nullptr) { + return refuse_web_action(message, output, written); + } + // The next account image carries both the material debit and character progression. + middleware::web_service::StatusResponse status{}; + status.value = queuezState.family4Version + 1; + middleware::web_service::ResponseShape shape{}; + web_service::resolve_response_shape(message.opcode, shape); + if (!middleware::web_service::encode_response( + message, shape, status, output, written)) { + clear_transaction(outcome); + return refuse_web_action(message, output, written); + } + } if (equipmentSwap != nullptr) { // Promise the Family-4 revision carrying this optimistic equip. auto* transaction = emplace_transaction(outcome); @@ -444,14 +464,13 @@ bool process(const ServiceRoute& route, clear_transaction(outcome); return refuse_web_action(message, output, written); } + // The response shape requires the extra bit, not a true value. middleware::web_service::StatusResponse status{}; status.value = transaction->update.after.family4Version; + middleware::web_service::ResponseShape shape{}; + web_service::resolve_response_shape(message.opcode, shape); if (!middleware::web_service::encode_response( - message, - middleware::web_service::ResponseShape::statusPair, - status, - output, - written)) { + message, shape, status, output, written)) { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=acquire stage=response result=fail"); @@ -480,12 +499,10 @@ bool process(const ServiceRoute& route, } middleware::web_service::StatusResponse status{}; status.value = transaction->update.after.family4Version; + middleware::web_service::ResponseShape shape{}; + web_service::resolve_response_shape(message.opcode, shape); if (!middleware::web_service::encode_response( - message, - middleware::web_service::ResponseShape::statusPair, - status, - output, - written)) { + message, shape, status, output, written)) { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=profile_acquire stage=response result=fail"); @@ -530,22 +547,9 @@ bool process(const ServiceRoute& route, } if (recordRewardGrant != nullptr) { auto* transaction = emplace_transaction(outcome); - std::array residents{}; - std::size_t residentCount = 0; - for (std::size_t index = 0; index < recordRewardGrant->rewardCount; ++index) { - const auto& reward = recordRewardGrant->rewards[index]; - if (reward.kind == state::RecordRewardKind::characterInstance - || reward.appendedProfileResident) { - residents[residentCount++] = reward.instanceSoid; - } - } - const bool staged = - transaction != nullptr - && queuez::stage_record_reward_grant(queuezState, - recordRewardGrant->accountSoid, - recordRewardGrant->characterSoid, - std::span(residents).first(residentCount), - transaction->update); + const bool staged = transaction != nullptr + && queuez::stage_record_reward_grant( + queuezState, *recordRewardGrant, transaction->update); if (!staged) { core::log::write(core::log::Channel::server, core::log::Level::warn, @@ -555,12 +559,10 @@ bool process(const ServiceRoute& route, } middleware::web_service::StatusResponse status{}; status.value = transaction->update.after.family4Version; + middleware::web_service::ResponseShape shape{}; + web_service::resolve_response_shape(message.opcode, shape); if (!middleware::web_service::encode_response( - message, - middleware::web_service::ResponseShape::statusPair, - status, - output, - written)) { + message, shape, status, output, written)) { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=ws1801 stage=reward_response result=fail"); @@ -578,43 +580,9 @@ bool process(const ServiceRoute& route, "ev=ws2400 stage=reward_preflight result=fail"); return refuse_web_action(message, output, written); } - bool staged = false; - std::int32_t stagedVersion = 0; - if (const auto* itemGrant = - std::get_if(&seasonPassReward->grant)) { - auto& update = transaction->update.emplace(); - staged = queuez::stage_item_acquisition(queuezState, - itemGrant->accountSoid, - itemGrant->characterSoid, - itemGrant->acquiredInstanceSoid, - true, - update); - stagedVersion = update.after.family4Version; - } else if (const auto* profileGrant = std::get_if( - &seasonPassReward->grant)) { - auto& update = transaction->update.emplace(); - staged = queuez::stage_profile_item_acquisition(queuezState, - profileGrant->accountSoid, - profileGrant->acquiredInstanceSoid, - profileGrant->actionSource, - profileGrant->appended, - update); - stagedVersion = update.after.family4Version; - } else if (const auto* bundle = - std::get_if(&seasonPassReward->grant)) { - staged = queuez::stage_direct_item_bundle(queuezState, - bundle->accountSoid, - bundle->characterSoid, - bundle->firstInstanceSoid, - bundle->itemCount, - stagedVersion); - } else if (const auto* resources = - std::get_if(&seasonPassReward->grant)) { - auto& update = transaction->update.emplace(); - staged = queuez::stage_record_reward_grant( - queuezState, resources->accountSoid, resources->characterSoid, {}, update); - stagedVersion = update.after.family4Version; - } + const bool staged = queuez::stage_record_reward_grant( + queuezState, seasonPassReward->grant, transaction->update); + const auto stagedVersion = transaction->update.after.family4Version; if (!staged) { core::log::write(core::log::Channel::server, core::log::Level::warn, diff --git a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp index 9889346bb..d741cc617 100644 --- a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp +++ b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp @@ -397,6 +397,8 @@ bool consume(Session& session, } } const bool artifactPurchase = transaction_if(outcome) != nullptr; + const bool vendorReputation = + transaction_if(outcome) != nullptr; const bool mutatesAccount = outcome.hasSelectCharacter || outcome.hasRecordClaim || outcome.hasArtifactReset || transaction_if(outcome) != nullptr @@ -408,7 +410,7 @@ bool consume(Session& session, || transaction_if(outcome) != nullptr || transaction_if(outcome) != nullptr || transaction_if(outcome) != nullptr - || transaction_if(outcome) != nullptr; + || transaction_if(outcome) != nullptr || vendorReputation; const bool presentsAcquisition = transaction_if(outcome) != nullptr || transaction_if(outcome) != nullptr @@ -534,7 +536,7 @@ bool consume(Session& session, session.activityKeepaliveDueTick = GetTickCount64() + kActivityKeepaliveIntervalMs; } const bool resyncsCommittedAccount = - hasPrecommittedAccountAction && !queuezPublication.hasState; + vendorReputation || (hasPrecommittedAccountAction && !queuezPublication.hasState); if (resyncsCommittedAccount) { bap::arm_account_resync_everywhere(); } diff --git a/Sunrise/src/server/bap/encrypted/internal.h b/Sunrise/src/server/bap/encrypted/internal.h index 9339a1196..5678aacf4 100644 --- a/Sunrise/src/server/bap/encrypted/internal.h +++ b/Sunrise/src/server/bap/encrypted/internal.h @@ -141,15 +141,7 @@ struct SeasonPassRewardTransaction { std::unique_ptr pending{}; - std::variant - - update{}; + queuez::RecordRewardGrant update{}; }; /** Optional side effect produced while decoding one authenticated service body. */ @@ -212,7 +204,8 @@ struct ServiceOutcome { std::unique_ptr, - std::unique_ptr>; + std::unique_ptr, + std::unique_ptr>; Transaction transaction{}; }; @@ -480,30 +473,6 @@ void append_queuez_notification(Scratch& scratch, queuez::SessionState& after) noexcept; -/** Publishes one Season package as new residents plus their acquisition descriptors. */ - -[[nodiscard]] bool append_season_pass_package_notification( - - Scratch& scratch, - - const queuez::SessionState& before, - - const state::PendingDirectItemBundle& mutation, - - std::uint16_t rewardIndex, - - std::span acquisitionPresentationRows, - - std::span key, - - std::span nonce, - - std::span response, - - std::size_t& written, - - queuez::SessionState& after) noexcept; - /** Publishes every item and the claimed record in one Family-4 revision. */ [[nodiscard]] bool append_record_reward_notification( diff --git a/Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp b/Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp index 5ab320f8d..3a0e59e8d 100644 --- a/Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp +++ b/Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp @@ -178,59 +178,6 @@ bool append_seasonal_experience_notification( return true; } -/** Publishes every item in one rank-one class package as an ordinary acquisition. */ -bool append_season_pass_package_notification( - Scratch& scratch, - const queuez::SessionState& before, - const state::PendingDirectItemBundle& mutation, - std::uint16_t rewardIndex, - std::span acquisitionPresentationRows, - std::span key, - std::span nonce, - std::span response, - std::size_t& written, - queuez::SessionState& after) noexcept { - after = before; - snapshot::Prepared prepared{}; - const std::size_t itemCount = mutation.itemCount; - // The item objects are indexed below, and every new resident must fit the peer's manifest. - if (!snapshot::prepare_season_pass_package( - scratch, before, mutation, rewardIndex, acquisitionPresentationRows, prepared) - || itemCount == 0 || prepared.family.objects.size() != itemCount + 2U - || before.family4ResidentCount + itemCount > before.family4Residents.size()) { - return false; - } - for (std::size_t index = 0; index < itemCount; ++index) { - const middleware::queuez::Object& object = prepared.family.objects[index]; - for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount; - ++residentIndex) { - if (before.family4Residents[residentIndex].objectSoid == object.version) { - return false; - } - } - after.family4Residents[after.family4ResidentCount++] = - queuez::ResidentObject{object.version, object.id}; - } - const middleware::queuez::Object& characterObject = prepared.family.objects[itemCount]; - std::size_t characterMatches = 0; - for (std::size_t index = 0; index < before.family4ResidentCount; ++index) { - const queuez::ResidentObject& resident = before.family4Residents[index]; - characterMatches += - static_cast(resident.objectSoid == characterObject.version - && resident.definitionId == characterObject.id); - } - if (characterMatches != 1) { - return false; - } - after.family4Version = prepared.family.version; - if (!queuez::valid(after) - || !queuez_frame::append_prepared(scratch, prepared, key, nonce, response, written)) { - after = before; - return false; - } - return true; -} - /** Publishes one prepared record-reward batch. */ bool append_record_reward_notification( Scratch& scratch, diff --git a/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp b/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp index f517e2753..7935775c0 100644 --- a/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp +++ b/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp @@ -1,12 +1,10 @@ -/** Family-4 reward updates: season pass packages and record rewards, one revision each. */ +/** Family-4 reward updates: record rewards, one revision each. */ #include #include #include -#include #include -#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" @@ -21,191 +19,10 @@ namespace sunrise::server::bap::encrypted::push::snapshot { namespace family4_datagen = middleware::datagen::family4; -/** Builds one atomic Season package update with one acquisition record per granted item. */ -bool prepare_season_pass_package( - Scratch& scratch, - const queuez::SessionState& before, - const state::PendingDirectItemBundle& mutation, - std::uint16_t rewardIndex, - std::span acquisitionPresentationRows, - Prepared& prepared) noexcept { - namespace character_layout = family4_datagen::character::layout; - const std::size_t itemCount = mutation.itemCount; - state::build_data::season_pass::Reward reward{}; - state::build_data::season_pass::Package package{}; - if (itemCount == 0 || itemCount > character_layout::kInventoryChangeRecordCapacity - || !mutation.prepared || !state::build_data::find_season_pass_reward(rewardIndex, reward) - || reward.quantity != 1 || reward.itemHash != mutation.sourceDefinitionHash - || !state::build_data::find_season_pass_package(reward.itemHash, package) - || !queuez::valid(before) || !before.family4Active || before.family4ResidentCount == 0 - || before.family4Version == (std::numeric_limits::max)() - || before.family4ResidentCount + itemCount > before.family4Residents.size()) { - return report_failure("season_package_session"); - } - - state::AccountState account{}; - if (!state::preview_direct_item_bundle(mutation, account)) { - return report_failure("season_package_preview"); - } - const std::optional selectedIndex = find_character_index(account); - Resolved selected{}; - if (!state::account::valid(account) || account.primarySoid != before.family4RootSoid - || account.primarySoid != mutation.accountSoid || !selectedIndex.has_value() - || *selectedIndex != mutation.characterIndex - || !resolve(account, *selectedIndex, selected)) { - return report_failure("season_package_account"); - } - const state::CharacterState& character = account.characters[*selectedIndex]; - if (character.soid != mutation.characterSoid - || mutation.expectedInventoryCount > character.inventory.count - || character.inventory.count != mutation.expectedInventoryCount + itemCount) { - return report_failure("season_package_count"); - } - - std::uint32_t itemInstanceObjectId = 0; - if (!middleware::datagen::object_id(queuez::kAccountFamilyType, - middleware::datagen::kItemInstanceSlot, - itemInstanceObjectId) - || before.family4Residents.front().objectSoid != account.primarySoid - || before.family4Residents.front().definitionId == 0) { - return report_failure("season_package_definitions"); - } - std::size_t characterResidentMatches = 0; - for (std::size_t index = 0; index < before.family4ResidentCount; ++index) { - const queuez::ResidentObject& resident = before.family4Residents[index]; - characterResidentMatches += - static_cast(resident.objectSoid == character.soid - && resident.definitionId == selected.characterObjectId); - } - if (characterResidentMatches != 1) { - return report_failure("season_package_character_resident"); - } - - family4_datagen::loadout::ResolvedInstances acquired{}; - const std::size_t firstGranted = mutation.expectedInventoryCount; - for (std::size_t grantIndex = 0; grantIndex < itemCount; ++grantIndex) { - const auto& granted = character.inventory.values[firstGranted + grantIndex]; - if (granted.instanceSoid != mutation.firstInstanceSoid + grantIndex - || granted.mutationSerial < 0) { - return report_failure("season_package_item_identity"); - } - for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount; - ++residentIndex) { - if (before.family4Residents[residentIndex].objectSoid == granted.instanceSoid) { - return report_failure("season_package_item_resident"); - } - } - std::size_t matches = 0; - for (std::size_t loadoutIndex = 0; loadoutIndex < selected.loadout.itemCount; - ++loadoutIndex) { - const auto& resolvedItem = selected.loadout.items[loadoutIndex]; - if (resolvedItem.instance.instanceSoid != granted.instanceSoid) { - continue; - } - acquired.items[grantIndex].equipmentSlot = resolvedItem.equipmentSlot; - acquired.items[grantIndex].instance = resolvedItem.instance; - if (resolvedItem.mutationSerial != granted.mutationSerial) { - return report_failure("season_package_item_serial"); - } - ++matches; - } - if (matches != 1) { - return report_failure("season_package_item_loadout"); - } - } - acquired.itemCount = itemCount; - - const Reservation reservation = reserve_prior(scratch, prepared); - const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset); - if (character_layout::kObjectSize > rawStorage.size() - || family4_datagen::account::layout::kObjectSize > rawStorage.size()) { - return report_failure("season_package_storage"); - } - - Prepared staged{}; - std::size_t compressedExtent = reservation.compressedWriteOffset; - std::size_t itemCursor = 0; - if (!append_items(scratch, - rawStorage, - itemInstanceObjectId, - acquired, - 0, - staged, - itemCursor, - compressedExtent) - || itemCursor != itemCount) { - clear_after(scratch, reservation); - return report_failure("season_package_item_objects"); - } - - const auto characterBytes = rawStorage.first(character_layout::kObjectSize); - if (!family4_datagen::character::encode( - character, selected.loadout, selected.lightEvaluation, characterBytes)) { - clear_after(scratch, reservation); - return report_failure("season_package_character_encode"); - } - auto& characterObject = *reinterpret_cast(characterBytes.data()); - if (characterObject.inventoryChanges.writeSlot != 0 - || characterObject.inventoryChanges.nextSequence != 0 - || !std::all_of(characterObject.inventoryChanges.records.cbegin(), - characterObject.inventoryChanges.records.cend(), - kChangeRecordIsZero)) { - clear_after(scratch, reservation); - return report_failure("season_package_change_state"); - } - for (std::size_t index = 0; index < itemCount; ++index) { - auto& change = characterObject.inventoryChanges.records[index]; - change.sequence = static_cast(index); - change.mutationSerial = character.inventory.values[firstGranted + index].mutationSerial; - change.kind = kChangeKind; - change.flags = kChangeFlags; - } - characterObject.inventoryChanges.writeSlot = static_cast(itemCount); - characterObject.inventoryChanges.nextSequence = static_cast(itemCount); - if (!apply_acquisition_presentation( - characterBytes, selected.loadout, acquisitionPresentationRows)) { - clear_after(scratch, reservation); - return report_failure("season_package_presentation"); - } - if (!append_object(scratch, - characterBytes, - selected.characterObjectId, - character.soid, - staged.objects[itemCount], - compressedExtent)) { - clear_after(scratch, reservation); - return report_failure("season_package_character_object"); - } - - const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize); - if (!family4_datagen::account::encode(account, accountBytes) - || !append_object(scratch, - accountBytes, - before.family4Residents.front().definitionId, - account.primarySoid, - staged.objects[itemCount + 1U], - compressedExtent)) { - clear_after(scratch, reservation); - return report_failure("season_package_account_object"); - } - - staged.rawClearSize = - (std::max)(reservation.rawClearSize, - reservation.rawWriteOffset - + (std::max)(character_layout::kObjectSize, - family4_datagen::account::layout::kObjectSize)); - staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent); - staged.family = middleware::queuez::Family{queuez::kAccountFamilyType, - account.primarySoid, - before.family4Version + 1, - 0, - std::span(staged.objects).first(itemCount + 2U)}; - if (!commit(staged, prepared)) { - clear_after(scratch, reservation); - return report_failure("season_package_commit"); - } - return true; -} +// The grant array holds both change lists in full; State bounds each list separately. +static_assert(state::kRecordRewardGrantCapacity + == family4_datagen::character::layout::kInventoryChangeRecordCapacity + + family4_datagen::account::layout::kProfileInventoryChangeRecordCapacity); /** Builds all record rewards and the pending claim into one Family-4 revision. */ bool prepare_record_reward_grant( @@ -217,7 +34,7 @@ bool prepare_record_reward_grant( Prepared& prepared) noexcept { namespace account_layout = family4_datagen::account::layout; namespace character_layout = family4_datagen::character::layout; - if (!mutation.prepared || mutation.rewardCount == 0 + if (!mutation.prepared || (mutation.rewardCount == 0 && mutation.wrapperFlags.empty()) || mutation.rewardCount > mutation.rewards.size() || !queuez::valid(before) || !queuez::valid(update.after) || !before.family4Active || before.family4ResidentCount == 0 || before.family4Version == (std::numeric_limits::max)() @@ -329,8 +146,14 @@ bool prepare_record_reward_grant( const auto characterBytes = rawStorage.first(character_layout::kObjectSize); const state::CharacterState& character = account.characters[mutation.characterIndex]; + state::unlocks::Table unlocks{}; + if (!state::preview_reward_unlocks(mutation, unlocks)) { + clear_after(scratch, reservation); + return report_failure("record_reward_unlocks"); + } + // Both objects must publish the same reward after-image, including spent claim credit. if (!family4_datagen::character::encode( - character, selected.loadout, selected.lightEvaluation, characterBytes)) { + character, selected.loadout, selected.lightEvaluation, characterBytes, unlocks)) { clear_after(scratch, reservation); return report_failure("record_reward_character_encode"); } @@ -346,7 +169,8 @@ bool prepare_record_reward_grant( std::size_t characterChanges = 0; for (std::size_t rewardIndex = 0; rewardIndex < mutation.rewardCount; ++rewardIndex) { const state::PreparedRecordReward& reward = mutation.rewards[rewardIndex]; - if (reward.kind == state::RecordRewardKind::profileStack) { + if (reward.kind == state::RecordRewardKind::profileStack + || reward.kind == state::RecordRewardKind::accountUnlock) { continue; } state::build_data::items::Definition definition{}; @@ -402,7 +226,7 @@ bool prepare_record_reward_grant( } const auto accountBytes = rawStorage.first(account_layout::kObjectSize); - if (!family4_datagen::account::encode(account, accountBytes)) { + if (!family4_datagen::account::encode(account, accountBytes, unlocks)) { clear_after(scratch, reservation); return report_failure("record_reward_account_encode"); } diff --git a/Sunrise/src/server/bap/encrypted/push/snapshot/internal.h b/Sunrise/src/server/bap/encrypted/push/snapshot/internal.h index 12721fa37..7411bca73 100644 --- a/Sunrise/src/server/bap/encrypted/push/snapshot/internal.h +++ b/Sunrise/src/server/bap/encrypted/push/snapshot/internal.h @@ -236,15 +236,6 @@ prepare_profile_item_acquisition(Scratch& scratch, std::span acquisitionPresentationRows, Prepared& prepared) noexcept; -/** Builds one package increment containing its new instances, character, and account objects. */ -[[nodiscard]] bool prepare_season_pass_package( - Scratch& scratch, - const queuez::SessionState& before, - const state::PendingDirectItemBundle& mutation, - std::uint16_t rewardIndex, - std::span acquisitionPresentationRows, - Prepared& prepared) noexcept; - /** Builds one atomic record-reward batch in resident, character, account order. */ [[nodiscard]] bool prepare_record_reward_grant( Scratch& scratch, diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp index bfb286dfd..8dad857d1 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp @@ -2,8 +2,12 @@ #include #include +#include +#include +#include #include "../../../../core/logging/log.h" +#include "../../../../middleware/crypto/random_bytes.h" #include "../../../../middleware/secure_channel/runtime.h" #include "../../../../state/account/account_state.h" #include "../../../../state/activity/destination/definition.h" @@ -52,6 +56,84 @@ selected_character(const state::AccountState& account) noexcept { return nullptr; } +/** Publishes and commits one world reward resolved through its installed reward definition. */ +[[nodiscard]] bool consume_world_record_reward(const WorldRewardRequest& request, + Session& session, + Scratch& scratch, + std::span response, + std::size_t& written, + bool& touchesScratch) noexcept { + const auto fail = [&](const char* reason) noexcept { + report_reward_refusal("world_publish", request.itemDefinitionIndex, reason); + return false; + }; + // A draw is not retained, so it waits for a buffer its whole frame is sure to fit. + if (response.size() < scratch.framed.size()) { + return false; + } + const std::unique_ptr pending( + new (std::nothrow) state::PendingRecordRewardGrant); + if (!pending) { + return fail("reward_allocation"); + } + std::uint64_t seed = 0; + if (!middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)))) { + return fail("random_source"); + } + const char* reason = "transaction"; + state::investment::store::Transaction transaction; + if (!transaction.ready()) { + return fail(reason); + } + const auto preparation = + state::prepare_item_reward(request.itemDefinitionIndex, + static_cast(request.quantity), + seed, + *pending, + &reason); + if (preparation == state::RewardPreparation::unresolvable) { + (void)bap::retire_world_reward(transaction, request, reason); + return false; + } + if (preparation != state::RewardPreparation::prepared) { + return fail(reason); + } + queuez::RecordRewardGrant update{}; + touchesScratch = true; + std::size_t framedSize = 0; + const bool staged = queuez::stage_record_reward_grant(session.queuez, *pending, update); + const bool encoded = + staged + && push::append_record_reward_notification(scratch, + session.queuez, + update, + *pending, + active_acquisition_presentation_rows(session), + session.sessionKey, + session.sendNonce, + scratch.framed, + framedSize) + && framedSize != 0; + if (!state::commit_record_reward(*pending)) { + return fail("inventory_commit"); + } + if (!bap::complete_world_reward(request.id) || !transaction.commit()) { + return fail("queue_commit"); + } + // No buffer fixes a failed stage or encode, so the grant commits and the row retires. + if (!encoded) { + bap::arm_account_resync_everywhere(); + return fail(!staged ? "inventory_stage" : "inventory_encode"); + } + std::copy_n(scratch.framed.begin(), framedSize, response.begin()); + written = framedSize; + middleware::secure_channel::advance_nonce(session.sendNonce); + session.queuez = update.after; + bap::arm_account_resync_elsewhere(session); + bap::arm_acquisition_presentation_hold(session); + return true; +} + /** Publishes and commits one character-inventory world reward. */ [[nodiscard]] bool consume_world_item_acquisition(const WorldRewardRequest& request, Session& session, @@ -59,6 +141,10 @@ selected_character(const state::AccountState& account) noexcept { std::span response, std::size_t& written, bool& touchesScratch) noexcept { + if (state::item_grant_route(request.itemDefinitionIndex) != state::ItemGrantRoute::quest) { + return consume_world_record_reward( + request, session, scratch, response, written, touchesScratch); + } state::investment::store::Transaction transaction; if (!transaction.ready()) { return false; @@ -69,38 +155,33 @@ selected_character(const state::AccountState& account) noexcept { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=world_acquisition result=fail reason=prepare"); - bap::settle_world_reward(); return false; } touchesScratch = true; queuez::ItemAcquisition acquisition{}; - if (!queuez::stage_item_acquisition(session.queuez, - pending.accountSoid, - pending.characterSoid, - pending.acquiredInstanceSoid, - pending.updates_account(), - acquisition)) { - core::log::write(core::log::Channel::server, - core::log::Level::warn, - "ev=queuez stage=world_acquisition result=fail reason=stage"); - bap::settle_world_reward(); - return false; - } + const bool staged = queuez::stage_item_acquisition(session.queuez, + pending.accountSoid, + pending.characterSoid, + pending.acquiredInstanceSoid, + pending.updates_account(), + acquisition); auto nextSendNonce = session.sendNonce; std::size_t framedSize = 0; - if (!push::append_item_acquisition_notification(scratch, - acquisition, - pending, - active_acquisition_presentation_rows(session), - session.sessionKey, - nextSendNonce, - scratch.framed, - framedSize) - || framedSize == 0 || framedSize > response.size()) { - core::log::write(core::log::Channel::server, - core::log::Level::warn, - "ev=queuez stage=world_acquisition result=fail reason=encode"); - bap::settle_world_reward(); + const bool encoded = + staged + && push::append_item_acquisition_notification(scratch, + acquisition, + pending, + active_acquisition_presentation_rows(session), + session.sessionKey, + nextSendNonce, + scratch.framed, + framedSize) + && framedSize != 0; + // Only a piggyback slot can be short; the grant waits for a full poll. + if (encoded && framedSize > response.size()) { + report_reward_refusal( + "world_acquisition", request.itemDefinitionIndex, "response_capacity"); return false; } if (!state::commit_item_acquisition(pending) || !bap::complete_world_reward(request.id) @@ -108,7 +189,14 @@ selected_character(const state::AccountState& account) noexcept { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=world_acquisition result=fail reason=commit"); - bap::settle_world_reward(); + return false; + } + // No buffer fixes a failed stage or encode, so the grant commits and the row retires. + if (!encoded) { + bap::arm_account_resync_everywhere(); + report_reward_refusal("world_acquisition", + request.itemDefinitionIndex, + !staged ? "inventory_stage" : "inventory_encode"); return false; } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); @@ -139,37 +227,31 @@ selected_character(const state::AccountState& account) noexcept { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=world_profile_acquisition result=fail reason=prepare"); - bap::settle_world_reward(); return false; } touchesScratch = true; queuez::ProfileItemAcquisition acquisition{}; - if (!queuez::stage_profile_item_acquisition(session.queuez, - pending.accountSoid, - pending.acquiredInstanceSoid, - pending.actionSource, - pending.appended, - acquisition)) { - core::log::write(core::log::Channel::server, - core::log::Level::warn, - "ev=queuez stage=world_profile_acquisition result=fail reason=stage"); - bap::settle_world_reward(); - return false; - } + const bool staged = queuez::stage_profile_item_acquisition(session.queuez, + pending.accountSoid, + pending.acquiredInstanceSoid, + pending.actionSource, + pending.appended, + acquisition); auto nextSendNonce = session.sendNonce; std::size_t framedSize = 0; - if (!push::append_profile_item_acquisition_notification(scratch, - acquisition, - pending, - session.sessionKey, - nextSendNonce, - scratch.framed, - framedSize) - || framedSize == 0 || framedSize > response.size()) { - core::log::write(core::log::Channel::server, - core::log::Level::warn, - "ev=queuez stage=world_profile_acquisition result=fail reason=encode"); - bap::settle_world_reward(); + const bool encoded = staged + && push::append_profile_item_acquisition_notification(scratch, + acquisition, + pending, + session.sessionKey, + nextSendNonce, + scratch.framed, + framedSize) + && framedSize != 0; + // Only a piggyback slot can be short; the grant waits for a full poll. + if (encoded && framedSize > response.size()) { + report_reward_refusal( + "world_profile_acquisition", request.itemDefinitionIndex, "response_capacity"); return false; } if (!state::commit_profile_item_acquisition(pending) || !bap::complete_world_reward(request.id) @@ -177,7 +259,14 @@ selected_character(const state::AccountState& account) noexcept { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=world_profile_acquisition result=fail reason=commit"); - bap::settle_world_reward(); + return false; + } + // No buffer fixes a failed stage or encode, so the grant commits and the row retires. + if (!encoded) { + bap::arm_account_resync_everywhere(); + report_reward_refusal("world_profile_acquisition", + request.itemDefinitionIndex, + !staged ? "inventory_stage" : "inventory_encode"); return false; } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); @@ -690,6 +779,9 @@ bool consume_deferred(Session& session, if (published) { return true; } + if (session.accountResyncArmed) { + return false; + } } if (consume_seasonal_experience_presentation( session, scratch, response, written, touchesScratch)) { diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp b/Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp index 0789ee199..29c5b445c 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp @@ -2,9 +2,6 @@ #include "queuez_reward_staging.h" -#include -#include - #include "../../../../core/logging/log.h" #include "../../../../middleware/secure_channel/runtime.h" @@ -56,70 +53,24 @@ stage_profile_item_acquisition_push(Scratch& scratch, return true; } -/** Stages the one grant a season pass rank owes, whichever of the four shapes it carries. */ +/** Publishes a reward batch before advancing the nonce and peer revision. */ [[nodiscard]] bool -stage_season_pass_reward(Scratch& scratch, +stage_record_reward_push(Scratch& scratch, const SessionState& before, - const SeasonPassRewardTransaction& reward, + const RecordRewardGrant& update, + const state::PendingRecordRewardGrant& pending, std::span presentationRows, std::span key, std::array& nonce, std::span response, std::size_t& written, SessionState& after) noexcept { - const auto& pending = *reward.pending; - if (const auto* itemUpdate = std::get_if(&reward.update)) { - const auto* itemPending = std::get_if(&pending.grant); - return itemPending != nullptr - && stage_item_acquisition_push(scratch, - *itemUpdate, - *itemPending, - presentationRows, - key, - nonce, - response, - written, - after); - } - if (const auto* profileUpdate = std::get_if(&reward.update)) { - const auto* profilePending = - std::get_if(&pending.grant); - return profilePending != nullptr - && stage_profile_item_acquisition_push( - scratch, *profileUpdate, *profilePending, key, nonce, response, written, after); - } - if (const auto* bundle = std::get_if(&pending.grant)) { - if (!push::append_season_pass_package_notification(scratch, - before, - *bundle, - pending.rewardIndex, - presentationRows, - key, - nonce, - response, - written, - after)) { - return false; - } - middleware::secure_channel::advance_nonce(nonce); - return true; - } - const auto* resourceUpdate = std::get_if(&reward.update); - const auto* resources = std::get_if(&pending.grant); - if (resourceUpdate == nullptr || resources == nullptr - || !push::append_record_reward_notification(scratch, - before, - *resourceUpdate, - *resources, - presentationRows, - key, - nonce, - response, - written)) { + if (!push::append_record_reward_notification( + scratch, before, update, pending, presentationRows, key, nonce, response, written)) { return false; } middleware::secure_channel::advance_nonce(nonce); - after = resourceUpdate->after; + after = update.after; return true; } @@ -184,28 +135,35 @@ bool stage_reward_outcome(Scratch& scratch, } if (const auto* record = transaction_if(outcome)) { if (record->pending == nullptr - || !push::append_record_reward_notification(scratch, - before, - record->update, - *record->pending, - presentationRows, - key, - nonce, - response, - written)) { + || !stage_record_reward_push(scratch, + before, + record->update, + *record->pending, + presentationRows, + key, + nonce, + response, + written, + after)) { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=record_reward result=fail"); return false; } - middleware::secure_channel::advance_nonce(nonce); - after = record->update.after; return true; } const auto* seasonPass = transaction_if(outcome); if (seasonPass == nullptr || seasonPass->pending == nullptr - || !stage_season_pass_reward( - scratch, before, *seasonPass, presentationRows, key, nonce, response, written, after)) { + || !stage_record_reward_push(scratch, + before, + seasonPass->update, + seasonPass->pending->grant, + presentationRows, + key, + nonce, + response, + written, + after)) { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=ws2400 stage=queuez_reward result=fail"); diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h b/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h index 2e32fa88a..489777526 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h @@ -7,6 +7,10 @@ #include "../../../../middleware/queuez/subscription.h" #include "definition.h" +namespace sunrise::state { +struct PendingRecordRewardGrant; +} + namespace sunrise::server::bap::encrypted::queuez { /** @return True when one peer queuez state is canonical for the implemented versions. */ @@ -158,14 +162,6 @@ namespace sunrise::server::bap::encrypted::queuez { bool updatesAccount, ItemAcquisition& acquisition) noexcept; -/** Validates one same-version bundle append and returns the revision its response may promise. */ -[[nodiscard]] bool stage_direct_item_bundle(const SessionState& before, - std::uint64_t accountSoid, - std::uint64_t characterSoid, - std::uint64_t firstInstanceSoid, - std::size_t itemCount, - std::int32_t& family4Version) noexcept; - /** * Stages one Family-4 version increment for a full resident account-object upsert. * A profile row with a nonzero action-source SOID must already be resident when its stack grows, @@ -192,6 +188,11 @@ namespace sunrise::server::bap::encrypted::queuez { std::span appendedResidents, RecordRewardGrant& grant) noexcept; +/** Stages a prepared record-reward grant, appending the residents its new instances need. */ +[[nodiscard]] bool stage_record_reward_grant(const SessionState& before, + const state::PendingRecordRewardGrant& pending, + RecordRewardGrant& grant) noexcept; + /** * Stages one Family-4 increment that removes an item resident and updates its character. * diff --git a/Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp b/Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp index f5ed8b243..e945bb314 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp @@ -7,6 +7,7 @@ #include "../../../../../core/logging/log.h" #include "../../../../../middleware/datagen/definitions.h" +#include "../../../../../state/runtime/runtime.h" #include "../queuez_state_validation.h" namespace sunrise::server::bap::encrypted::queuez { @@ -472,55 +473,30 @@ bool stage_item_acquisition(const SessionState& before, return staged; } -/** Checks that a fixed bundle can append all residents in one Family-4 increment. */ -bool stage_direct_item_bundle(const SessionState& before, - std::uint64_t accountSoid, - std::uint64_t characterSoid, - std::uint64_t firstInstanceSoid, - std::size_t itemCount, - std::int32_t& family4Version) noexcept { - family4Version = 0; - std::uint32_t accountDefinitionId = 0; - std::uint32_t characterDefinitionId = 0; - std::uint32_t itemDefinitionId = 0; - if (!valid(before) || !before.family4Active || accountSoid == 0 || characterSoid == 0 - || firstInstanceSoid == 0 || itemCount == 0 || accountSoid != before.family4RootSoid - || before.family4ResidentCount == 0 - || itemCount > before.family4Residents.size() - before.family4ResidentCount - || itemCount - 1U > (std::numeric_limits::max)() - firstInstanceSoid - || before.family4Version == (std::numeric_limits::max)() - || !middleware::datagen::object_id( - kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId) - || !middleware::datagen::object_id( - kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId) - || !middleware::datagen::object_id( - kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemDefinitionId)) { +/** Stages one atomic record-reward manifest update. */ +bool stage_record_reward_grant(const SessionState& before, + const state::PendingRecordRewardGrant& pending, + RecordRewardGrant& grant) noexcept { + grant = {}; + if (!pending.prepared || pending.rewardCount > pending.rewards.size()) { return false; } - - std::size_t accountMatches = 0; - std::size_t characterMatches = 0; - for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount; - ++residentIndex) { - const ResidentObject& resident = before.family4Residents[residentIndex]; - accountMatches += static_cast(resident.objectSoid == accountSoid - && resident.definitionId == accountDefinitionId); - characterMatches += static_cast( - resident.objectSoid == characterSoid && resident.definitionId == characterDefinitionId); - for (std::size_t itemIndex = 0; itemIndex < itemCount; ++itemIndex) { - if (resident.objectSoid == firstInstanceSoid + itemIndex) { - return false; - } + std::array residents{}; + std::size_t count = 0; + for (std::size_t i = 0; i < pending.rewardCount; ++i) { + const auto& reward = pending.rewards[i]; + if (reward.kind == state::RecordRewardKind::characterInstance + || reward.appendedProfileResident) { + residents[count++] = reward.instanceSoid; } } - if (accountMatches != 1 || characterMatches != 1) { - return false; - } - family4Version = before.family4Version + 1; - return true; + return stage_record_reward_grant(before, + pending.accountSoid, + pending.characterSoid, + std::span(residents).first(count), + grant); } -/** Stages one atomic record-reward manifest update. */ bool stage_record_reward_grant(const SessionState& before, std::uint64_t accountSoid, std::uint64_t characterSoid, diff --git a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp index 2e1d6e41b..00d816f11 100644 --- a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp +++ b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp @@ -303,6 +303,11 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas reason = "item_state"; return committed; } + if (auto* reputation = transaction_if(outcome)) { + reason = "vendor_reputation"; + return report_commit(state::commit_vendor_reputation(*reputation), + "ev=vendor_reputation stage=transaction_commit result=fail"); + } if (auto* transaction = transaction_if(outcome)) { const bool committed = transaction->pending != nullptr && state::commit_profile_item_acquisition(*transaction->pending); diff --git a/Sunrise/src/server/bap/internal.h b/Sunrise/src/server/bap/internal.h index 81303d9e0..d0c7412b7 100644 --- a/Sunrise/src/server/bap/internal.h +++ b/Sunrise/src/server/bap/internal.h @@ -25,6 +25,10 @@ #include "encrypted/queuez/definition.h" #include "runtime.h" +namespace sunrise::state::investment::store { +class Transaction; +} + namespace sunrise::server::bap { /** One session per transport peer slot, so a connection id indexes this array directly. */ @@ -323,6 +327,9 @@ struct ReplicationEpochPublication { bool staged{}; }; +/** Logs one refused reward stage with its native index and a specific reason. */ +void report_reward_refusal(const char* stage, std::uint16_t index, const char* reason) noexcept; + /** Which inventory one world reward lands in. */ enum class WorldRewardKind : std::uint8_t { item, @@ -332,6 +339,7 @@ enum class WorldRewardKind : std::uint8_t { /** One reward earned in world, held until a Family-4 peer can publish it. */ struct WorldRewardRequest { std::uint64_t id{}; + std::uint32_t definitionHash{}; std::int32_t quantity{}; std::uint16_t itemDefinitionIndex{}; WorldRewardKind kind{}; @@ -522,7 +530,7 @@ unique_activity_link_locked(const state::activity::SessionBinding& binding, session_scenario_layout(const Session& session, state::build_data::scenarios::Definition& output) noexcept; -/** Commits every queued world reward with no presentation and empties the queue. */ +/** Commits saved world rewards with no presentation until one must wait. */ void drain_world_rewards() noexcept; /** Arms every other Family-4 peer after the origin publishes a complete account mutation. */ @@ -541,13 +549,18 @@ void arm_acquisition_presentation_hold(Session& session) noexcept; [[nodiscard]] bool arm_world_profile_item_acquisition(std::uint16_t itemDefinitionIndex, std::int32_t quantity) noexcept; -/** Reads the oldest queued world reward without removing it. */ +/** Reads the oldest saved world reward that can still be granted, retiring rows that cannot. */ [[nodiscard]] bool current_world_reward(WorldRewardRequest& request) noexcept; /** Removes the world reward returned by current_world_reward. */ [[nodiscard]] bool complete_world_reward(std::uint64_t id) noexcept; -/** Commits the queued reward with no flyout once its presentation cannot be built. */ +/** Deletes an ungrantable reward on the caller's open savepoint, commits, and logs why. */ +[[nodiscard]] bool retire_world_reward(state::investment::store::Transaction& transaction, + const WorldRewardRequest& request, + const char* reason) noexcept; + +/** Grants the oldest saved reward with no flyout, retiring rewards that can never be granted. */ void settle_world_reward() noexcept; /** Queues one transient XP item update so the native HUD presents a seasonal XP gain. */ diff --git a/Sunrise/src/server/web_service/web_service_record_actions.cpp b/Sunrise/src/server/web_service/web_service_record_actions.cpp index 34dd754be..e8ca97d84 100644 --- a/Sunrise/src/server/web_service/web_service_record_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_record_actions.cpp @@ -1,6 +1,5 @@ /** Triumph, title and season-pass reward actions the web service prepares from one request. */ -#include #include #include #include @@ -8,12 +7,9 @@ #include "../../core/logging/log.h" #include "../../middleware/crypto/random_bytes.h" -#include "../../middleware/encoding/byte_order.h" #include "../../middleware/web_service/messages/opcode1801.h" #include "../../middleware/web_service/messages/opcode1821.h" #include "../../middleware/web_service/messages/opcode2400.h" -#include "../../state/account/account_state.h" -#include "../../state/build_data/items/item_catalog.h" #include "../../state/build_data/runtime.h" #include "../../state/progression/season_pass_reward_catalog.h" #include "../../state/runtime/runtime.h" @@ -23,226 +19,6 @@ namespace sunrise::server::web_service { -namespace { - -/** Prepares one rank-one class package without changing account State. */ -[[nodiscard]] bool -prepare_premium_class_package(const state::build_data::season_pass::Package& package, - state::PendingDirectItemBundle& mutation) noexcept { - std::array itemIndices{}; - if (package.itemCount == 0 || package.itemCount > itemIndices.size()) { - return false; - } - for (std::size_t index = 0; index < package.itemCount; ++index) { - state::build_data::items::Definition definition{}; - if (!state::build_data::find_item_definition_hash(package.items[index], definition)) { - return false; - } - itemIndices[index] = definition.definitionIndex; - } - return state::prepare_direct_item_bundle( - package.definitionHash, std::span(itemIndices).first(package.itemCount), mutation); -} - -/** Expands the manifest-authored destination package into all nine material stacks. */ -[[nodiscard]] bool -prepare_destination_resource_bundle(state::PendingSeasonPassReward& grant) noexcept { - namespace pass = state::progression::season_pass; - std::array rewards{}; - for (std::size_t index = 0; index < rewards.size(); ++index) { - state::build_data::items::Definition definition{}; - if (!state::build_data::find_item_definition_hash(pass::kDestinationResourceHashes[index], - definition)) { - return false; - } - rewards[index] = {definition.definitionIndex, pass::kDestinationResourceQuantity}; - } - auto& mutation = grant.grant.emplace(); - return state::prepare_record_reward_grant(rewards, {}, mutation); -} - -/** Chooses one installed weapon or selected-class armour item from an auto-decrypting engram. */ -template -[[nodiscard]] std::span -class_armour_pool(state::CharacterClass characterClass, - const std::array& titan, - const std::array& hunter, - const std::array& warlock) noexcept { - switch (characterClass) { - case state::CharacterClass::hunter: - return hunter; - case state::CharacterClass::warlock: - return warlock; - case state::CharacterClass::titan: - default: - return titan; - } -} - -/** - * Picks one random installed item from the engram's weapon and selected-class armour pools. - * @param itemIndex Receives the picked item definition index; unchanged on failure. - * @return False when no character is selected, the engram is unknown, or no pool item is installed. - */ -[[nodiscard]] bool choose_engram_reward(std::uint32_t engramHash, - std::uint16_t& itemIndex) noexcept { - namespace pass = state::progression::season_pass; - std::span weapons; - std::span armour; - const state::AccountState account = state::account_snapshot(); - const state::CharacterState* character = nullptr; - for (std::size_t index = 0; index < account.characterCount; ++index) { - if (account.characters[index].selected) { - character = &account.characters[index]; - break; - } - } - if (character == nullptr) { - return false; - } - - if (engramHash == pass::kLegendaryEngramHash) { - weapons = pass::kLegendaryEngramWeapons; - armour = class_armour_pool(character->characterClass, - pass::kLegendaryTitanArmour, - pass::kLegendaryHunterArmour, - pass::kLegendaryWarlockArmour); - } else if (engramHash == pass::kExoticEngramHash) { - weapons = pass::kExoticEngramWeapons; - armour = class_armour_pool(character->characterClass, - pass::kExoticTitanArmour, - pass::kExoticHunterArmour, - pass::kExoticWarlockArmour); - } else { - return false; - } - - std::array randomBytes{}; - if (!middleware::crypto::random::fill(randomBytes)) { - return false; - } - const std::size_t count = weapons.size() + armour.size(); - const std::size_t first = middleware::encoding::read_u32_le(randomBytes) % count; - for (std::size_t offset = 0; offset < count; ++offset) { - const std::size_t selected = (first + offset) % count; - const std::uint32_t hash = - selected < weapons.size() ? weapons[selected] : armour[selected - weapons.size()]; - state::build_data::items::Definition definition{}; - if (state::build_data::find_item_definition_hash(hash, definition)) { - itemIndex = definition.definitionIndex; - return true; - } - } - return false; -} - -/** Stat plugs occupy the four ordinary socket lanes after the six the armour piece itself uses. */ -constexpr std::size_t kSeasonArmourStatLaneBase = 6; -/** One roll names one lane of each of the four armour stats. */ -constexpr std::size_t kSeasonArmourStatLaneCount = 4; - -struct SeasonArmourRoll { - std::uint16_t rewardIndex{}; - std::uint32_t itemHash{}; - std::array statPlugs{}; -}; - -/** - * Exact rolls shown by the free, early premium, and high-stat pass tiles. - * TODO: extract; the pass reward row names the item, and no package field names its stat plugs. - */ -constexpr std::array kSeasonArmourRolls{{ - // Hunter - {29, 3097544525U, {1232924472U, 3898228147U, 1177742666U, 1322519294U}}, - {106, 3097544525U, {2191854732U, 3198072315U, 1322519294U, 597281635U}}, - {139, 3097544525U, {3072193746U, 772656086U, 2692705068U, 1300719726U}}, - {3, 3750210364U, {1232924472U, 3898228147U, 1177742666U, 757147114U}}, - {78, 3750210364U, {2120002858U, 1526511067U, 1277668557U, 3014984195U}}, - {111, 3750210364U, {2600679334U, 3955940376U, 3026009912U, 115819390U}}, - {24, 2930001572U, {1232924472U, 2518640481U, 1177742666U, 4237052128U}}, - {97, 2930001572U, {2191854732U, 2440285137U, 1322519294U, 597281635U}}, - {134, 2930001572U, {4286256569U, 2148570570U, 594234536U, 2664898188U}}, - {10, 3136019014U, {2191854732U, 2236091344U, 1177742666U, 2596709069U}}, - {83, 3136019014U, {3407231789U, 1798836563U, 1322519294U, 3203727595U}}, - {120, 3136019014U, {3346214146U, 2399358832U, 594234536U, 2701881372U}}, - // Titan - {30, 1214477175U, {1232924472U, 3898228147U, 1177742666U, 1322519294U}}, - {107, 1214477175U, {479726201U, 176934377U, 1322519294U, 597281635U}}, - {140, 1214477175U, {2868325782U, 2387165386U, 2692705068U, 1300719726U}}, - {4, 287888126U, {1232924472U, 3898228147U, 1177742666U, 757147114U}}, - {79, 287888126U, {2120002858U, 1369119565U, 1277668557U, 3014984195U}}, - {112, 287888126U, {2148570570U, 2148570570U, 3026009912U, 115819390U}}, - {25, 1585947570U, {1232924472U, 2518640481U, 1177742666U, 4237052128U}}, - {98, 1585947570U, {1232924472U, 1533918171U, 1322519294U, 597281635U}}, - {135, 1585947570U, {2868325782U, 1087628722U, 594234536U, 2664898188U}}, - {11, 1131831128U, {2191854732U, 2236091344U, 1177742666U, 2596709069U}}, - {84, 1131831128U, {1232924472U, 4002613733U, 1322519294U, 3203727595U}}, - {121, 1131831128U, {2387165386U, 2148570570U, 594234536U, 2701881372U}}, - // Warlock - {31, 1173249516U, {1232924472U, 3898228147U, 1177742666U, 1322519294U}}, - {108, 1173249516U, {2191854732U, 2433764085U, 1322519294U, 597281635U}}, - {141, 1173249516U, {2868325782U, 3072193746U, 2692705068U, 1300719726U}}, - {5, 327547301U, {1232924472U, 3898228147U, 1177742666U, 757147114U}}, - {80, 327547301U, {1232924472U, 3554741641U, 1277668557U, 3014984195U}}, - {113, 327547301U, {4248662490U, 1281324436U, 3026009912U, 115819390U}}, - {26, 119457531U, {1232924472U, 2518640481U, 1177742666U, 4237052128U}}, - {99, 119457531U, {1232924472U, 1858911761U, 1322519294U, 597281635U}}, - {136, 119457531U, {3198618292U, 643846500U, 594234536U, 2664898188U}}, - {12, 674876967U, {2191854732U, 2236091344U, 1177742666U, 2596709069U}}, - {85, 674876967U, {2576224072U, 1534361879U, 1322519294U, 3203727595U}}, - {122, 674876967U, {643846500U, 4248662490U, 594234536U, 2701881372U}}, -}}; - -/** Replaces native stat plugs with the fixed roll represented by one known pass tile. */ -[[nodiscard]] bool apply_season_armour_roll(std::uint16_t rewardIndex, - state::PendingItemAcquisition& mutation) noexcept { - const auto roll = std::find_if(kSeasonArmourRolls.begin(), - kSeasonArmourRolls.end(), - [rewardIndex](const SeasonArmourRoll& candidate) { - return candidate.rewardIndex == rewardIndex; - }); - if (roll == kSeasonArmourRolls.end()) { - return true; - } - if (mutation.inventoryIndex >= mutation.afterCharacter.inventory.count) { - return false; - } - auto& acquired = mutation.afterCharacter.inventory.values[mutation.inventoryIndex]; - state::build_data::items::Definition item{}; - state::build_data::items::details::Definition detail{}; - if (acquired.definitionHash != mutation.acquiredDefinitionHash - || acquired.definitionHash != roll->itemHash - || !state::build_data::find_item_definition_hash(acquired.definitionHash, item) - || !state::build_data::find_configured_item_detail(item.definitionIndex, detail) - || detail.definitionIndex != item.definitionIndex - || detail.ordinarySocketCount < kSeasonArmourStatLaneBase + kSeasonArmourStatLaneCount - || detail.ordinarySocketCount > acquired.sockets.plugs.size()) { - return false; - } - - state::account::inventory::Sockets sockets{}; - sockets.policy = state::account::inventory::SocketPolicy::authored; - sockets.plugCount = detail.ordinarySocketCount; - for (std::size_t lane = 0; lane < sockets.plugCount; ++lane) { - const std::uint16_t plugIndex = detail.initialPlugIndices[lane]; - if (plugIndex == state::build_data::items::details::kUnavailableItemIndex) { - continue; - } - state::build_data::items::Definition plug{}; - if (!state::build_data::find_item_definition_index(plugIndex, plug)) { - return false; - } - sockets.plugs[lane] = plug.definitionHash; - } - for (std::size_t index = 0; index < roll->statPlugs.size(); ++index) { - sockets.plugs[kSeasonArmourStatLaneBase + index] = roll->statPlugs[index]; - } - acquired.sockets = sockets; - return state::account::inventory::valid(acquired.sockets); -} - -} // namespace - /** Reports an opcode-1801 claim failure. */ void report_record_claim(const middleware::web_service::Message& message, std::string_view reason, @@ -286,52 +62,6 @@ void report_record_reward(const middleware::web_service::Message& message, write_warning(line, count); } -/** Prepares one direct Season-pass item, returning a failure reason or null. */ -[[nodiscard]] const char* prepare_direct_reward(std::uint16_t itemIndex, - std::int32_t quantity, - state::PendingSeasonPassReward& grant) noexcept { - if (quantity <= 0) { - return "quantity"; - } - state::build_data::items::Definition definition{}; - if (!state::build_data::find_item_definition_index(itemIndex, definition)) { - return "item_definition"; - } - - state::build_data::items::details::Definition detail{}; - state::build_data::inventory::buckets::Descriptor bucket{}; - if (!state::build_data::find_configured_item_detail(itemIndex, detail) - || detail.definitionIndex != itemIndex || detail.definitionHash != definition.definitionHash - || detail.bucketId != definition.bucketId - || !state::build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)) { - return "item_detail_or_bucket"; - } - - namespace bucket_domain = state::build_data::inventory::buckets; - namespace detail_domain = state::build_data::items::details; - if (bucket.arraySelector == bucket_domain::ArraySelector::profile) { - if (detail.instancedDefinitionState != detail_domain::InstancedDefinitionState::stackable) { - return "profile_item_instanced"; - } - auto& mutation = grant.grant.emplace(); - return state::prepare_profile_item_acquisition_for_item(itemIndex, quantity, mutation) - ? nullptr - : "profile_state"; - } - if (bucket.arraySelector == bucket_domain::ArraySelector::character) { - if (quantity != 1) { - return "character_item_quantity"; - } - auto& mutation = grant.grant.emplace(); - if (!state::prepare_item_acquisition_for_item(itemIndex, mutation)) { - return "state"; - } - mutation.profileChanged = false; - return nullptr; - } - return "unsupported_inventory_array"; -} - /** Whether a claimed record owes a reward, and whether that reward could be prepared. */ enum class RecordRewardPreparation : std::uint8_t { /** The record grants nothing, so only the claim is written. */ @@ -369,8 +99,9 @@ prepare_record_reward(const middleware::web_service::Message& message, message, "storage", recordIndex, rewards[0].itemDefinitionIndex, rewards[0].quantity); return RecordRewardPreparation::failed; } - if (!state::prepare_record_reward_grant( - std::span(rewards).first(rewardCount), recordIndex, *grant)) { + if (state::prepare_record_reward_grant( + std::span(rewards).first(rewardCount), recordIndex, *grant) + != state::RewardPreparation::prepared) { clear_mutation(outcome); report_record_reward( message, "state", recordIndex, rewards[0].itemDefinitionIndex, rewards[0].quantity); @@ -427,50 +158,21 @@ void claim_season_pass_reward(const middleware::web_service::Message& message, if (grant == nullptr) { return fail("storage"); } - state::build_data::season_pass::Package package{}; - if (state::build_data::find_season_pass_package(reward.itemHash, package)) { - auto& bundle = grant->grant.emplace(); - if (!prepare_premium_class_package(package, bundle)) { - clear_mutation(outcome); - return fail("package_grant"); - } - } else if (reward.itemHash == pass::kDestinationResourceBundleHash) { - if (!prepare_destination_resource_bundle(*grant)) { - clear_mutation(outcome); - return fail("resource_bundle"); - } - } else if (reward.itemHash == pass::kLegendaryEngramHash - || reward.itemHash == pass::kExoticEngramHash) { - std::uint16_t decryptedItemIndex = 0; - if (!choose_engram_reward(reward.itemHash, decryptedItemIndex)) { - clear_mutation(outcome); - return fail("engram_pool"); - } - if (const char* reason = prepare_direct_reward(decryptedItemIndex, 1, *grant)) { - clear_mutation(outcome); - return fail(reason); - } - } else { - if (const char* reason = prepare_direct_reward( - item.definitionIndex, static_cast(reward.quantity), *grant)) { - clear_mutation(outcome); - return fail(reason); - } - if (auto* armour = std::get_if(&grant->grant); - armour != nullptr && !apply_season_armour_roll(request.rewardIndex, *armour)) { - clear_mutation(outcome); - return fail("armour_roll"); - } + std::uint64_t seed = 0; + if (!middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)))) { + clear_mutation(outcome); + return fail("random_source"); + } + const char* reason = nullptr; + if (!state::prepare_season_pass_reward(request.rewardIndex, seed, *grant, &reason)) { + clear_mutation(outcome); + return fail(reason != nullptr ? reason : "reward_preparation"); } - // The claim is world state the moment the grant is accepted, so it is written before the - // reply and the push are staged from the banks. + // Stage the claim before encoding the reply and account push from the transaction's banks. if (!state::claim_season_pass_reward(request.rewardIndex)) { clear_mutation(outcome); return fail("claim_rejected"); } - grant->sourceDefinitionHash = reward.itemHash; - grant->rewardIndex = request.rewardIndex; - grant->prepared = true; } /** Decodes one opcode-1801 Triumphs claim and reports the record it names. */ diff --git a/Sunrise/src/server/web_service/web_service_runtime.cpp b/Sunrise/src/server/web_service/web_service_runtime.cpp index 6559beaa2..854cbad0c 100644 --- a/Sunrise/src/server/web_service/web_service_runtime.cpp +++ b/Sunrise/src/server/web_service/web_service_runtime.cpp @@ -413,6 +413,7 @@ bool consume(std::span request, middleware::web_service::ResponseShape shape{}; resolve_response_shape(message.opcode, shape); + // WS901's extra Boolean has no known effect contract; keep it clear. middleware::web_service::StatusResponse status{}; if (awaits_family4_version(message.opcode)) { // Nothing is published from here. A staged mutation re-encodes this with its own revision. diff --git a/Sunrise/src/server/web_service/web_service_runtime.h b/Sunrise/src/server/web_service/web_service_runtime.h index 16214197b..9da430e57 100644 --- a/Sunrise/src/server/web_service/web_service_runtime.h +++ b/Sunrise/src/server/web_service/web_service_runtime.h @@ -12,6 +12,7 @@ #include "../../middleware/web_service/messages/opcode206.h" #include "../../state/account/inventory/seen_state.h" #include "../../state/runtime/runtime.h" +#include "../../state/runtime/state_vendor_reputation_runtime.h" namespace sunrise::server::web_service { @@ -43,7 +44,8 @@ struct Outcome { std::unique_ptr, std::unique_ptr, std::unique_ptr, - std::unique_ptr>; + std::unique_ptr, + std::unique_ptr>; Mutation mutation{}; }; diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index 2315707ff..d80e8a1ce 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -1,6 +1,5 @@ /** Vendor, bounty, exchange and quest actions the web service prepares from one request. */ -#include #include #include #include @@ -636,9 +635,7 @@ constexpr std::uint32_t kAbsentNameHash = 0x811C9DC5U; } /** - * Settles one resolved vendor row, in the order a row's behaviours are tried. - * A row is a bounty roll, an exchange or a grant. The row does not say which, so each is tried in - * turn and the first that claims the row owns it. Both vendor opcodes end here. + * The first matching behavior owns the sale, including a refused placeholder action. * @param message Request being answered. * @param opcode Opcode to report under. * @param vendorIndex Vendor the request names. @@ -670,6 +667,55 @@ void settle_vendor_row(const middleware::web_service::Message& message, return; } const std::span price = vendor_domain::cost_entries(row); + + if (vendorIndex >= 0 && rowIndex >= 0 + && vendorIndex <= (std::numeric_limits::max)() + && rowIndex <= (std::numeric_limits::max)()) { + auto* reward = emplace_mutation(outcome); + if (reward == nullptr) { + report_purchase(opcode, "fail", "storage", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + const char* refusal = "storage"; + const auto disposition = + state::prepare_vendor_rank_reward_sale(static_cast(vendorIndex), + static_cast(rowIndex), + *reward, + &refusal); + if (disposition == state::VendorReputationDisposition::prepared) { + report_purchase( + opcode, "ok", "rank_reward", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + clear_mutation(outcome); + if (disposition == state::VendorReputationDisposition::refused) { + report_purchase(opcode, "fail", refusal, vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + } + if (vendorIndex >= 0 && rowIndex >= 0 + && vendorIndex <= (std::numeric_limits::max)() + && rowIndex <= (std::numeric_limits::max)()) { + auto* reputation = emplace_mutation(outcome); + if (reputation == nullptr) { + report_purchase(opcode, "fail", "storage", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + const auto disposition = + state::prepare_vendor_reputation(static_cast(vendorIndex), + static_cast(rowIndex), + *reputation); + if (disposition == state::VendorReputationDisposition::prepared) { + report_purchase(opcode, "ok", "reputation", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + clear_mutation(outcome); + if (disposition == state::VendorReputationDisposition::refused) { + report_purchase( + opcode, "fail", "reputation", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + } std::uint16_t rolledBounty = kUnavailableDefinitionIndex; if (roll_vendor_bounty(vendorIndex, categoryIndex, rolledBounty)) { report_purchase(opcode, @@ -727,8 +773,9 @@ void settle_vendor_row(const middleware::web_service::Message& message, } /** - * Prepares one opcode-904 quest acquire. - * A quest names a vendor row exactly as a purchase does and takes the same grant path. + * Routes opcode 904 to rowless rank claims or sale-backed vendor acquisition. + * @param message Decoded Web Service envelope. + * @param outcome Receives the prepared mutation; no inventory is committed here. */ void acquire_quest(const middleware::web_service::Message& message, Outcome& outcome) noexcept { namespace quest = middleware::web_service::messages::opcode904; @@ -749,6 +796,54 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out return; } const std::int32_t row = request.saleIndex; + // Logical -1 is the only absent sale marker; all three selectors must be nonnegative. + if (row < quest::kAbsentSaleIndex || request.vendorIndex < 0 || request.slotIndex < 0 + || request.third < 0) { + report_purchase(quest::kOpcode, + "fail", + "selectors", + request.vendorIndex, + row, + kUnavailableDefinitionIndex); + return; + } + if (row == quest::kAbsentSaleIndex) { + auto* reward = emplace_mutation(outcome); + if (reward == nullptr) { + clear_mutation(outcome); + report_purchase(quest::kOpcode, + "fail", + "reward_prepare", + request.vendorIndex, + row, + kUnavailableDefinitionIndex); + return; + } + const auto disposition = state::prepare_vendor_rank_reward_interaction( + static_cast(request.vendorIndex), + static_cast(request.slotIndex), + static_cast(request.third), + *reward); + if (disposition == state::VendorReputationDisposition::prepared) { + report_purchase(quest::kOpcode, + "ok", + "rank_reward", + request.vendorIndex, + row, + kUnavailableDefinitionIndex); + return; + } + clear_mutation(outcome); + if (disposition == state::VendorReputationDisposition::refused) { + report_purchase(quest::kOpcode, + "fail", + "rank_reward", + request.vendorIndex, + row, + kUnavailableDefinitionIndex); + return; + } + } const char* reason = "unknown"; // A row of -1 says the tile is not a sale row at all, so the installed array answers it. // Reading the slot as a sale row here would grant whatever sits at that row. @@ -777,9 +872,7 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out } /** - * Prepares one opcode-901 vendor purchase, for any Tower vendor. - * The sale row names an item-definition index, so this hands over to the Collections grant, and - * the row's own cost is charged with it: a purchase that cannot pay grants nothing. + * Prepares one installed vendor sale through the shared settlement path. */ void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept { namespace purchase = middleware::web_service::messages::opcode901; diff --git a/Sunrise/src/state/account/account_state.h b/Sunrise/src/state/account/account_state.h index 855003945..ffb08645b 100644 --- a/Sunrise/src/state/account/account_state.h +++ b/Sunrise/src/state/account/account_state.h @@ -79,6 +79,10 @@ enum class CharacterClass : std::uint8_t { warlock = 2, }; +/** Number of character classes represented on the wire. */ +inline constexpr std::size_t kCharacterClassCount = + static_cast(CharacterClass::warlock) + 1; + /** Default movement entry. Each subclass offers 3, as entries 4, 5 and 6 of its group. */ inline constexpr std::uint8_t kDefaultMovementAbilityEntry = 4; diff --git a/Sunrise/src/state/build_data/build_data_runtime.cpp b/Sunrise/src/state/build_data/build_data_runtime.cpp index 95b04df2c..cc8b96549 100644 --- a/Sunrise/src/state/build_data/build_data_runtime.cpp +++ b/Sunrise/src/state/build_data/build_data_runtime.cpp @@ -21,6 +21,7 @@ #include "nodes/node_catalog.h" #include "progressions/progression_catalog.h" #include "records/record_catalog.h" +#include "rewards/reward_catalog.h" #include "runtime.h" #include "runtime/build_data_catalog_runtime.h" #include "runtime/domain_markers.h" @@ -30,6 +31,7 @@ #include "sobjects/sobject_catalog.h" #include "socket_entry_lists/socket_entry_list_catalog.h" #include "spawn_sets/spawn_set_catalog.h" +#include "vendors/reputation_sale_catalog.h" #include "vendors/vendor_catalog.h" namespace sunrise::state::build_data { @@ -78,6 +80,11 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept { return false; } persistenceState.enabled = true; + if (!vendors::load_reputation_sales(module)) { + runtime::persistence::clear_locked(persistenceState); + ReleaseSRWLockExclusive(&persistenceState.lock); + return false; + } cache::records::DomainCounts counts{}; const cache::LoadStatus status = @@ -119,6 +126,12 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept { }; if (!constants::replace(cachedConstants) || !content::replace(domains.named) || !content::seal() || !items::replace(domains.items) || !collectibles::replace(domains.collectibles) + || !rewards::replace({domains.rewardPools, + domains.rewardEntries, + domains.rewardItems, + domains.rewardInstructions, + domains.rewardModifiers, + domains.rewardSockets}) || !material_requirements::replace(domains.materialRequirementSets) || !inventory::buckets::replace(domains.inventoryBuckets) || !socket_entry_lists::replace(domains.socketEntryLists) diff --git a/Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp b/Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp index 8becdbfde..1173c1ede 100644 --- a/Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp +++ b/Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp @@ -54,7 +54,13 @@ namespace { && counts.progressionSteps <= output.progressionSteps.size() && counts.seasonPassRewards <= output.seasonPassRewards.size() && counts.seasonPassPackages <= output.seasonPassPackages.size() - && counts.bounties <= output.bounties.size(); + && counts.bounties <= output.bounties.size() + && counts.rewardPools <= output.rewardPools.size() + && counts.rewardEntries <= output.rewardEntries.size() + && counts.rewardItems <= output.rewardItems.size() + && counts.rewardInstructions <= output.rewardInstructions.size() + && counts.rewardModifiers <= output.rewardModifiers.size() + && counts.rewardSockets <= output.rewardSockets.size(); } /** @return The header's row counts, as platform sizes. */ @@ -96,6 +102,12 @@ namespace { header.seasonPassRewardCount, header.seasonPassPackageCount, header.bountyCount, + header.rewardPoolCount, + header.rewardEntryCount, + header.rewardItemCount, + header.rewardInstructionCount, + header.rewardModifierCount, + header.rewardSocketCount, }; } diff --git a/Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp b/Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp index 8b455acc2..2c7812b6a 100644 --- a/Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp +++ b/Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp @@ -126,6 +126,13 @@ void clear(records::MutableDomains output) noexcept { std::fill( output.seasonPassPackages.begin(), output.seasonPassPackages.end(), season_pass::Package{}); std::fill(output.bounties.begin(), output.bounties.end(), bounties::Definition{}); + std::fill(output.rewardPools.begin(), output.rewardPools.end(), rewards::Pool{}); + std::fill(output.rewardEntries.begin(), output.rewardEntries.end(), rewards::Entry{}); + std::fill(output.rewardItems.begin(), output.rewardItems.end(), rewards::Item{}); + std::fill( + output.rewardInstructions.begin(), output.rewardInstructions.end(), rewards::Instruction{}); + std::fill(output.rewardModifiers.begin(), output.rewardModifiers.end(), rewards::Modifier{}); + std::fill(output.rewardSockets.begin(), output.rewardSockets.end(), rewards::SocketOverride{}); } /** Computes the exact file size for every record array. */ @@ -168,7 +175,13 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe && add_records(counts.progressionSteps, sizeof(records::ProgressionStepRecord), size) && add_records(counts.seasonPassRewards, sizeof(records::SeasonPassRewardRecord), size) && add_records(counts.seasonPassPackages, sizeof(records::SeasonPassPackageRecord), size) - && add_records(counts.bounties, sizeof(records::BountyRecord), size); + && add_records(counts.bounties, sizeof(records::BountyRecord), size) + && add_records(counts.rewardPools, sizeof(records::RewardPoolRecord), size) + && add_records(counts.rewardEntries, sizeof(records::RewardEntryRecord), size) + && add_records(counts.rewardItems, sizeof(records::RewardItemRecord), size) + && add_records(counts.rewardInstructions, sizeof(records::RewardInstructionRecord), size) + && add_records(counts.rewardModifiers, sizeof(records::RewardModifierRecord), size) + && add_records(counts.rewardSockets, sizeof(records::RewardSocketOverrideRecord), size); } /** Reads every payload array and checks the decoded domains as one transaction. */ @@ -288,6 +301,25 @@ bool read_payload(HANDLE file, valid = valid && read_domain( file, output.bounties.first(counts.bounties), checksum); + valid = valid + && read_domain( + file, output.rewardPools.first(counts.rewardPools), checksum); + valid = valid + && read_domain( + file, output.rewardEntries.first(counts.rewardEntries), checksum); + valid = valid + && read_domain( + file, output.rewardItems.first(counts.rewardItems), checksum); + valid = valid + && read_domain( + file, output.rewardInstructions.first(counts.rewardInstructions), checksum); + valid = valid + && read_domain( + file, output.rewardModifiers.first(counts.rewardModifiers), checksum); + valid = valid + && read_domain( + file, output.rewardSockets.first(counts.rewardSockets), checksum); + if (!valid) { return false; } @@ -332,6 +364,12 @@ bool read_payload(HANDLE file, output.seasonPassRewards.first(counts.seasonPassRewards), output.seasonPassPackages.first(counts.seasonPassPackages), output.bounties.first(counts.bounties), + output.rewardPools.first(counts.rewardPools), + output.rewardEntries.first(counts.rewardEntries), + output.rewardItems.first(counts.rewardItems), + output.rewardInstructions.first(counts.rewardInstructions), + output.rewardModifiers.first(counts.rewardModifiers), + output.rewardSockets.first(counts.rewardSockets), }); } diff --git a/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp b/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp index b5f17d125..52cff67e1 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp @@ -13,6 +13,7 @@ #include "../../nodes/node_catalog.h" #include "../../progressions/progression_catalog.h" #include "../../records/record_catalog.h" +#include "../../rewards/reward_catalog.h" #include "../../scenarios/scenario_catalog.h" #include "../../season_pass/season_pass_catalog.h" #include "../../sobjects/sobject_catalog.h" @@ -170,7 +171,13 @@ template && counts.progressionSteps <= domains.progressionSteps.size() && counts.seasonPassRewards <= domains.seasonPassRewards.size() && counts.seasonPassPackages <= domains.seasonPassPackages.size() - && counts.bounties <= domains.bounties.size(); + && counts.bounties <= domains.bounties.size() + && counts.rewardPools <= domains.rewardPools.size() + && counts.rewardEntries <= domains.rewardEntries.size() + && counts.rewardItems <= domains.rewardItems.size() + && counts.rewardInstructions <= domains.rewardInstructions.size() + && counts.rewardModifiers <= domains.rewardModifiers.size() + && counts.rewardSockets <= domains.rewardSockets.size(); } } // namespace @@ -236,6 +243,12 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept { || !strictly_ordered(domains.named, named_less) || !items::valid(domains.items) || !collectibles::valid(domains.collectibles) || !strictly_ordered(domains.collectibles, collectible_less) + || !rewards::valid({domains.rewardPools, + domains.rewardEntries, + domains.rewardItems, + domains.rewardInstructions, + domains.rewardModifiers, + domains.rewardSockets}) || !material_requirements::valid(domains.materialRequirementSets) || !strictly_ordered(domains.materialRequirementSets, material_requirement_less) || !inventory::buckets::valid(domains.inventoryBuckets) @@ -276,11 +289,32 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept { || !hash_names::valid(domains.hashNames)) { return false; } + // Reward rows are dense by item index, so each must name its item or be unavailable. + if (domains.rewardItems.size() != domains.items.size()) { + return false; + } for (std::size_t index = 0; index < domains.items.size(); ++index) { - if (domains.items[index].definitionIndex != index) { + if (domains.items[index].definitionIndex != index + || (domains.rewardItems[index].definitionHash != 0 + && domains.rewardItems[index].definitionHash + != domains.items[index].definitionHash)) { return false; } } + for (const auto& reward : domains.seasonPassRewards) { + if (reward.itemHash == 0) { + continue; + } + if (reward.itemIndex >= domains.items.size() + || domains.items[reward.itemIndex].definitionHash != reward.itemHash) { + return false; + } + for (std::size_t i = 0; i < reward.socketCount; ++i) { + if (!rewards::valid_socket(reward.sockets[i], domains.items.size())) { + return false; + } + } + } for (const material_requirements::Definition& definition : domains.materialRequirementSets) { for (std::size_t index = 0; index < definition.requirementCount; ++index) { const std::uint16_t itemIndex = definition.requirements[index].itemDefinitionIndex; diff --git a/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp index 90927bb6d..ff4472531 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp @@ -1,3 +1,5 @@ +#include + #include "codec.h" namespace sunrise::state::build_data::cache::records { @@ -6,6 +8,22 @@ namespace { /** Cache padding fields are always written as zero. */ constexpr unsigned int kReservedFieldValue = 0; +/** A condition slot past a pass row's count holds what a default instruction encodes to. */ +bool unused_condition(const RewardInstructionRecord& record) noexcept { + const rewards::Instruction unused{}; + return record.opcode == static_cast(unused.opcode) + && record.bank == static_cast(unused.bank) + && record.reserved == kReservedFieldValue && record.operand == unused.operand; +} + +/** A socket slot past a pass row's count holds what a default override encodes to. */ +bool unused_socket(const RewardSocketOverrideRecord& record) noexcept { + const rewards::SocketOverride unused{}; + return record.socketType == unused.socketType && record.plugItem == unused.plugItem + && record.plugSet == unused.plugSet && record.rollSet == unused.rollSet + && record.selection == unused.selection; +} + } // namespace /** Flattens the 12 buckets into 3 parallel arrays with no per-bucket padding. */ @@ -65,16 +83,22 @@ bool encode(const progressions::Definition& value, ProgressionRecord& record) no value.stepOffset, value.stepCount, static_cast(value.scope), + static_cast(value.repeatLastStep), }; return true; } /** Decodes one progression definition; the complete-domain validator checks its step range. */ bool decode(const ProgressionRecord& record, progressions::Definition& value) noexcept { + value = {}; + if (record.repeatLastStep > 1) { + return false; + } value = {record.definitionIndex, record.stepOffset, record.stepCount, - static_cast(record.scope)}; + static_cast(record.scope), + record.repeatLastStep != 0}; return true; } @@ -90,7 +114,7 @@ bool decode(const ProgressionStepRecord& record, progressions::Step& value) noex return true; } -/** Encodes one season pass reward row with its padding zeroed. */ +/** Encodes one season pass reward row with canonical unused socket slots. */ bool encode(const season_pass::Reward& value, SeasonPassRewardRecord& record) noexcept { record = {}; record.itemHash = value.itemHash; @@ -98,13 +122,38 @@ bool encode(const season_pass::Reward& value, SeasonPassRewardRecord& record) no record.itemIndex = value.itemIndex; record.claimFlagIndex = value.claimFlagIndex; record.requiredRank = value.requiredRank; + record.socketCount = value.socketCount; + record.conditionCount = value.conditionCount; + if (record.conditionCount > record.condition.size() + || record.socketCount > record.sockets.size()) { + return false; + } + for (std::size_t i = 0; i < record.conditionCount; ++i) { + if (!encode(value.condition[i], record.condition[i])) { + return false; + } + } + for (std::size_t i = 0; i < record.sockets.size(); ++i) { + if (!encode(i < record.socketCount ? value.sockets[i] : rewards::SocketOverride{}, + record.sockets[i])) { + return false; + } + } return true; } -/** Decodes one season pass reward row after checking its padding. */ +/** Decodes one season pass reward row after checking its padding and unused slots. */ bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) noexcept { value = {}; - if (record.reserved != decltype(record.reserved){}) { + if (record.reserved != decltype(record.reserved){} + || record.conditionCount > record.condition.size() + || record.socketCount > record.sockets.size()) { + return false; + } + const auto unusedConditions = std::span(record.condition).subspan(record.conditionCount); + const auto unusedSockets = std::span(record.sockets).subspan(record.socketCount); + if (!std::all_of(unusedConditions.begin(), unusedConditions.end(), unused_condition) + || !std::all_of(unusedSockets.begin(), unusedSockets.end(), unused_socket)) { return false; } value.itemHash = record.itemHash; @@ -112,6 +161,18 @@ bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) no value.itemIndex = record.itemIndex; value.claimFlagIndex = record.claimFlagIndex; value.requiredRank = record.requiredRank; + value.socketCount = record.socketCount; + value.conditionCount = record.conditionCount; + for (std::size_t i = 0; i < value.conditionCount; ++i) { + if (!decode(record.condition[i], value.condition[i])) { + return false; + } + } + for (std::size_t i = 0; i < value.socketCount; ++i) { + if (!decode(record.sockets[i], value.sockets[i])) { + return false; + } + } return true; } diff --git a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp new file mode 100644 index 000000000..02c1a7ec2 --- /dev/null +++ b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp @@ -0,0 +1,163 @@ +#include + +#include "codec.h" + +namespace sunrise::state::build_data::cache::records { +namespace { + +/** A selection slot past an item's count holds what a default selection encodes to. */ +bool unused_selection(const RewardSelectionRecord& record) noexcept { + const rewards::Selection unused{}; + return record.categoryHash == unused.categoryHash && record.count == unused.count; +} + +} // namespace + +/** Encodes one pool identity and its member range. */ +bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept { + record = {}; + record.definitionHash = value.definitionHash; + record.entries = {value.entries.first, value.entries.count}; + return true; +} + +/** Decodes one pool; the complete-domain validator checks its member range. */ +bool decode(const RewardPoolRecord& record, rewards::Pool& value) noexcept { + value = {}; + value.definitionHash = record.definitionHash; + value.entries = {record.entries.first, record.entries.count}; + return true; +} + +/** Encodes one pool entry with its dependent bank ranges. */ +bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept { + record = {}; + record.itemIndex = value.itemIndex; + record.poolIndex = value.poolIndex; + record.supplementalIndex = value.supplementalIndex; + record.supplementalMissing = static_cast(value.supplementalMissing); + record.quantity = value.quantity; + record.categoryHash = value.categoryHash; + record.weight = value.weight; + record.condition = {value.condition.first, value.condition.count}; + record.modifiers = {value.modifiers.first, value.modifiers.count}; + record.sockets = {value.sockets.first, value.sockets.count}; + return true; +} + +/** Decodes one pool entry, refusing an absent-bank marker without a supplemental reference. */ +bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept { + value = {}; + if (record.supplementalMissing > 1 + || (record.supplementalMissing != 0 && record.supplementalIndex == rewards::kAbsent)) { + return false; + } + value.itemIndex = record.itemIndex; + value.poolIndex = record.poolIndex; + value.supplementalIndex = record.supplementalIndex; + value.supplementalMissing = record.supplementalMissing != 0; + value.quantity = record.quantity; + value.categoryHash = record.categoryHash; + value.weight = record.weight; + value.condition = {record.condition.first, record.condition.count}; + value.modifiers = {record.modifiers.first, record.modifiers.count}; + value.sockets = {record.sockets.first, record.sockets.count}; + return true; +} + +/** Encodes one item's wrapper and acquisition flag. */ +bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept { + record = {}; + record.definitionHash = value.definitionHash; + record.poolIndex = value.poolIndex; + record.acquiredFlag = value.acquiredFlag; + for (std::size_t i = 0; i < record.selections.size(); ++i) { + record.selections[i] = {value.selections[i].categoryHash, value.selections[i].count}; + } + record.selectionCount = value.selectionCount; + record.flags = value.flags; + return true; +} + +/** Decodes one item's wrapper and checks its unused selections; references are checked later. */ +bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept { + value = {}; + if (record.selectionCount > record.selections.size()) { + return false; + } + const auto unused = std::span(record.selections).subspan(record.selectionCount); + if (!std::all_of(unused.begin(), unused.end(), unused_selection)) { + return false; + } + value.definitionHash = record.definitionHash; + value.poolIndex = record.poolIndex; + value.acquiredFlag = record.acquiredFlag; + for (std::size_t i = 0; i < record.selectionCount; ++i) { + value.selections[i] = {record.selections[i].categoryHash, record.selections[i].count}; + } + value.selectionCount = record.selectionCount; + value.flags = record.flags; + return true; +} + +/** Encodes one bound condition instruction. */ +bool encode(const rewards::Instruction& value, RewardInstructionRecord& record) noexcept { + record = {}; + record.opcode = static_cast(value.opcode); + record.bank = static_cast(value.bank); + record.operand = value.operand; + return true; +} + +/** Decodes one bound condition instruction, refusing an unknown opcode or bank. */ +bool decode(const RewardInstructionRecord& record, rewards::Instruction& value) noexcept { + value = {}; + if (record.reserved != 0 || !unlocks::decode_opcode(record.opcode, value.opcode)) { + return false; + } + value.bank = static_cast(record.bank); + value.operand = record.operand; + return unlocks::valid(value); +} + +/** Encodes one conditional weight modifier. */ +bool encode(const rewards::Modifier& value, RewardModifierRecord& record) noexcept { + record = {}; + record.condition = {value.condition.first, value.condition.count}; + record.valueIndex = value.valueIndex; + record.value = value.value; + return true; +} + +/** Decodes one weight modifier; the complete-domain validator checks its condition range. */ +bool decode(const RewardModifierRecord& record, rewards::Modifier& value) noexcept { + value = {}; + value.condition = {record.condition.first, record.condition.count}; + value.valueIndex = record.valueIndex; + value.value = record.value; + return true; +} + +/** Encodes one socket override. */ +bool encode(const rewards::SocketOverride& value, RewardSocketOverrideRecord& record) noexcept { + record = {}; + record.socketType = value.socketType; + record.plugItem = value.plugItem; + record.plugSet = value.plugSet; + record.rollSet = value.rollSet; + record.selection = value.selection; + return true; +} + +/** Decodes one socket override; the complete-domain validator checks its plug reference. */ +bool decode(const RewardSocketOverrideRecord& record, rewards::SocketOverride& value) noexcept { + value = {}; + value.socketType = record.socketType; + value.plugItem = record.plugItem; + value.plugSet = record.plugSet; + value.rollSet = record.rollSet; + value.selection = record.selection; + return true; +} + +} // namespace sunrise::state::build_data::cache::records diff --git a/Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp index 1aa812d58..3a28f9509 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp @@ -42,6 +42,9 @@ bool encode(const vendors::Definition& value, VendorDefinitionRecord& record) no record.installedCount = value.installedCount; record.saleCount = value.saleCount; record.thirdCount = value.thirdCount; + record.factionIndexRaw = value.factionIndexRaw; + record.factionProgressionIndex = value.factionProgressionIndex; + record.factionHash = value.factionHash; return true; } @@ -71,6 +74,9 @@ bool decode(const VendorDefinitionRecord& record, vendors::Definition& value) no value.installedCount = record.installedCount; value.saleCount = record.saleCount; value.thirdCount = record.thirdCount; + value.factionIndexRaw = record.factionIndexRaw; + value.factionProgressionIndex = record.factionProgressionIndex; + value.factionHash = record.factionHash; return true; } diff --git a/Sunrise/src/state/build_data/cache/records/codec.h b/Sunrise/src/state/build_data/cache/records/codec.h index d535bbc78..b0c688c17 100644 --- a/Sunrise/src/state/build_data/cache/records/codec.h +++ b/Sunrise/src/state/build_data/cache/records/codec.h @@ -90,13 +90,39 @@ namespace sunrise::state::build_data::cache::records { [[nodiscard]] bool encode(const progressions::Step& value, ProgressionStepRecord& record) noexcept; [[nodiscard]] bool decode(const ProgressionStepRecord& record, progressions::Step& value) noexcept; +/** + * Encodes one season pass reward into its canonical disk form. + * @param value Runtime reward row. + * @param record Receives the disk row; may be partial on failure. + * @return False when counts or nested reward data are invalid. + */ [[nodiscard]] bool encode(const season_pass::Reward& value, SeasonPassRewardRecord& record) noexcept; + +/** + * Decodes one canonical season pass reward. + * @param record Disk row to validate and decode. + * @param value Receives the runtime row; may be partial on failure. + * @return False when padding, counts, or nested reward data are invalid. + */ [[nodiscard]] bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) noexcept; +/** + * Encodes one season pass wrapper into its canonical disk form. + * @param value Runtime wrapper package. + * @param record Receives the disk row; unchanged when the item count is invalid. + * @return False when the item count exceeds fixed storage. + */ [[nodiscard]] bool encode(const season_pass::Package& value, SeasonPassPackageRecord& record) noexcept; + +/** + * Decodes one canonical season pass wrapper. + * @param record Disk row to validate and decode. + * @param value Receives the runtime package, or is cleared on failure. + * @return False when padding or the item count is invalid. + */ [[nodiscard]] bool decode(const SeasonPassPackageRecord& record, season_pass::Package& value) noexcept; @@ -165,4 +191,21 @@ namespace sunrise::state::build_data::cache::records { [[nodiscard]] bool decode(const VendorInstalledRowRecord& record, vendors::InstalledRow& value) noexcept; +[[nodiscard]] bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardPoolRecord& record, rewards::Pool& value) noexcept; +[[nodiscard]] bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept; +[[nodiscard]] bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept; +[[nodiscard]] bool encode(const rewards::Instruction& value, + RewardInstructionRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardInstructionRecord& record, + rewards::Instruction& value) noexcept; +[[nodiscard]] bool encode(const rewards::Modifier& value, RewardModifierRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardModifierRecord& record, rewards::Modifier& value) noexcept; +[[nodiscard]] bool encode(const rewards::SocketOverride& value, + RewardSocketOverrideRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardSocketOverrideRecord& record, + rewards::SocketOverride& value) noexcept; + } // namespace sunrise::state::build_data::cache::records diff --git a/Sunrise/src/state/build_data/cache/records/domains.h b/Sunrise/src/state/build_data/cache/records/domains.h index 1dd1cf125..fad271b72 100644 --- a/Sunrise/src/state/build_data/cache/records/domains.h +++ b/Sunrise/src/state/build_data/cache/records/domains.h @@ -19,6 +19,7 @@ #include "../../nodes/definition.h" #include "../../progressions/definition.h" #include "../../records/definition.h" +#include "../../rewards/definition.h" #include "../../scenarios/definition.h" #include "../../season_pass/definition.h" #include "../../sobjects/sobject_catalog.h" @@ -67,6 +68,12 @@ struct DomainCounts { std::size_t seasonPassRewards{}; std::size_t seasonPassPackages{}; std::size_t bounties{}; + std::size_t rewardPools{}; + std::size_t rewardEntries{}; + std::size_t rewardItems{}; + std::size_t rewardInstructions{}; + std::size_t rewardModifiers{}; + std::size_t rewardSockets{}; }; /** Fixed caller storage used while decoding the cache domains. */ @@ -110,6 +117,12 @@ struct MutableDomains { std::span seasonPassRewards; std::span seasonPassPackages; std::span bounties; + std::span rewardPools; + std::span rewardEntries; + std::span rewardItems; + std::span rewardInstructions; + std::span rewardModifiers; + std::span rewardSockets; }; /** Read-only complete views used for the checks and for cache encoding. */ @@ -152,6 +165,12 @@ struct Domains { std::span seasonPassRewards; std::span seasonPassPackages; std::span bounties; + std::span rewardPools; + std::span rewardEntries; + std::span rewardItems; + std::span rewardInstructions; + std::span rewardModifiers; + std::span rewardSockets; }; } // namespace sunrise::state::build_data::cache::records diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index 3d5fc5845..46acbcdfb 100644 --- a/Sunrise/src/state/build_data/cache/records/format.h +++ b/Sunrise/src/state/build_data/cache/records/format.h @@ -20,6 +20,7 @@ #include "../../nodes/definition.h" #include "../../progressions/definition.h" #include "../../records/definition.h" +#include "../../rewards/definition.h" #include "../../scenarios/definition.h" #include "../../season_pass/definition.h" #include "../../sobjects/sobject_catalog.h" @@ -30,8 +31,8 @@ namespace sunrise::state::build_data::cache::records { /** These 8 ASCII bytes mark a Sunrise build-data file. */ inline constexpr std::array kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'}; -/** Bump when stored layouts or extracted values change; other versions are rebuilt. */ -inline constexpr std::uint32_t kCacheFormatVersion = 67; +/** Format 75 stores reward-catalog domains and season-pass wrappers; other versions are rebuilt. */ +inline constexpr std::uint32_t kCacheFormatVersion = 75; /** Signed -1 on disk means there is no equipment slot. */ inline constexpr std::int8_t kAbsentEquipmentSlot = -1; /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */ @@ -105,6 +106,12 @@ struct Header { std::uint32_t seasonPassRewardCount{}; std::uint32_t seasonPassPackageCount{}; std::uint32_t bountyCount{}; + std::uint32_t rewardPoolCount{}; + std::uint32_t rewardEntryCount{}; + std::uint32_t rewardItemCount{}; + std::uint32_t rewardInstructionCount{}; + std::uint32_t rewardModifierCount{}; + std::uint32_t rewardSocketCount{}; gameplay::entity_position_profiles::Fingerprint positionFingerprint{}; InvestmentConstants constants{}; std::uint64_t payloadChecksum{}; @@ -296,6 +303,7 @@ struct ProgressionRecord { std::uint16_t stepOffset{}; std::uint8_t stepCount{}; std::uint8_t scope{}; + std::uint8_t repeatLastStep{}; }; /** Disk form of one progression rank step. */ @@ -303,6 +311,24 @@ struct ProgressionStepRecord { std::int32_t cost{}; }; +/** Disk form of one reward socket override. */ +struct RewardSocketOverrideRecord { + std::uint16_t socketType{}; + std::uint16_t plugItem{}; + std::uint16_t plugSet{}; + std::uint16_t rollSet{}; + std::uint32_t selection{}; +}; + +/** One normalized reward-condition instruction. */ +struct RewardInstructionRecord { + std::uint8_t opcode{}; + std::uint8_t bank{}; + /** Must be zero for canonical cache encoding. */ + std::uint16_t reserved{}; + std::uint32_t operand{}; +}; + /** Disk form of one season pass reward row. */ struct SeasonPassRewardRecord { std::uint32_t itemHash{}; @@ -310,7 +336,11 @@ struct SeasonPassRewardRecord { std::uint16_t itemIndex{}; std::uint16_t claimFlagIndex{season_pass::kUnavailableFlagIndex}; std::uint8_t requiredRank{}; - /** Must be zero, so the packed reward row always matches. */ + std::array sockets{}; + std::uint8_t socketCount{}; + std::array condition{}; + std::uint8_t conditionCount{}; + /** Must be zero for canonical cache encoding. */ std::array reserved{}; }; @@ -319,7 +349,7 @@ struct SeasonPassPackageRecord { std::uint32_t definitionHash{}; std::array items{}; std::uint8_t itemCount{}; - /** Must be zero, so the packed wrapper row always matches. */ + /** Must be zero for canonical cache encoding. */ std::array reserved{}; }; @@ -545,6 +575,9 @@ struct VendorDefinitionRecord { std::uint16_t installedCount{}; std::uint16_t saleCount{}; std::uint16_t thirdCount{}; + std::uint16_t factionIndexRaw{}; + std::uint16_t factionProgressionIndex{vendors::kUnavailableFactionProgressionIndex}; + std::uint32_t factionHash{}; }; /** Disk form of one cost entry of a vendor sale row. */ @@ -584,13 +617,78 @@ struct RosterGroupRecord { std::array slotIndices{}; }; +/** One declared category selection in a reward wrapper. */ +struct RewardSelectionRecord { + std::uint32_t categoryHash{}; + std::uint32_t count{}; +}; + +/** Offset and count into one reward bank. */ +struct RewardRangeRecord { + std::uint32_t first{}; + std::uint32_t count{}; +}; + +/** Pool identity and its contiguous member range. */ +struct RewardPoolRecord { + std::uint32_t definitionHash{}; + RewardRangeRecord entries{}; +}; + +/** Reward item, pool or supplemental reference, weight and dependent bank ranges. */ +struct RewardEntryRecord { + std::uint16_t itemIndex{}; + std::uint16_t poolIndex{}; + std::uint16_t supplementalIndex{}; + std::uint8_t supplementalMissing{}; + std::uint32_t quantity{}; + std::uint32_t categoryHash{}; + float weight{}; + RewardRangeRecord condition{}; + RewardRangeRecord modifiers{}; + RewardRangeRecord sockets{}; +}; + +/** Native item-to-pool relation and acquisition flag. */ +struct RewardItemRecord { + std::uint32_t definitionHash{}; + std::uint16_t poolIndex{}; + std::uint16_t acquiredFlag{}; + std::array selections{}; + std::uint8_t selectionCount{}; + std::uint32_t flags{}; +}; + +/** Conditional replacement weight or indexed value reference. */ +struct RewardModifierRecord { + RewardRangeRecord condition{}; + std::uint16_t valueIndex{}; + float value{}; +}; + #pragma pack(pop) +static_assert(sizeof(RewardSelectionRecord) == 2 * sizeof(std::uint32_t)); +static_assert(sizeof(RewardRangeRecord) == 2 * sizeof(std::uint32_t)); +static_assert(sizeof(RewardPoolRecord) == sizeof(std::uint32_t) + sizeof(RewardRangeRecord)); +static_assert(sizeof(RewardEntryRecord) + == 3 * sizeof(std::uint16_t) + 2 * sizeof(std::uint32_t) + sizeof(float) + + sizeof(std::uint8_t) + 3 * sizeof(RewardRangeRecord)); +static_assert(sizeof(RewardItemRecord) + == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + sizeof(std::uint8_t) + + rewards::kSelectionCapacity * sizeof(RewardSelectionRecord)); +static_assert(sizeof(RewardModifierRecord) + == sizeof(RewardRangeRecord) + sizeof(std::uint16_t) + sizeof(float)); +static_assert(sizeof(RewardSocketOverrideRecord) + == 4 * sizeof(std::uint16_t) + sizeof(std::uint32_t)); +static_assert(sizeof(RewardInstructionRecord) + == 2 * sizeof(std::uint8_t) + sizeof(std::uint16_t) + sizeof(std::uint32_t)); + static_assert(sizeof(Prefix) == kCacheMagic.size() + sizeof(std::uint32_t)); static_assert(sizeof(InvestmentConstants) == constants::kCharacterStatRowCount + 3 * sizeof(std::uint8_t)); static_assert(sizeof(Header) - == kCacheMagic.size() + 39 * sizeof(std::uint32_t) + 3 * sizeof(std::uint64_t) + == kCacheMagic.size() + 45 * sizeof(std::uint32_t) + 3 * sizeof(std::uint64_t) + sizeof(InvestmentConstants) + sizeof(gameplay::entity_position_profiles::Fingerprint)); static_assert(sizeof(SpawnPointRecord) @@ -598,7 +696,7 @@ static_assert(sizeof(SpawnPointRecord) + sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t)); static_assert(sizeof(VendorIndexRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t)); static_assert(sizeof(VendorDefinitionRecord) - == 14 * sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t)); + == 15 * sizeof(std::uint32_t) + 6 * sizeof(std::uint16_t)); static_assert(sizeof(VendorSaleCostRecord) == sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t)); static_assert(sizeof(VendorSaleRowRecord) == sizeof(std::int32_t) + vendors::kSaleCostCapacity * sizeof(VendorSaleCostRecord) @@ -628,10 +726,12 @@ static_assert(sizeof(RosterGroupRecord) == 2 * sizeof(std::uint32_t) + sizeof(std::uint16_t) + 2 * scenarios::kRosterSlotCapacity * sizeof(std::uint8_t) + scenarios::kRosterSlotCapacity * sizeof(std::uint16_t)); -static_assert(sizeof(ProgressionRecord) == 2 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t)); +static_assert(sizeof(ProgressionRecord) == 2 * sizeof(std::uint16_t) + 3 * sizeof(std::uint8_t)); static_assert(sizeof(ProgressionStepRecord) == sizeof(std::int32_t)); static_assert(sizeof(SeasonPassRewardRecord) - == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 4 * sizeof(std::uint8_t)); + == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 6 * sizeof(std::uint8_t) + + rewards::kSocketsPerItem * sizeof(RewardSocketOverrideRecord) + + season_pass::kConditionCapacity * sizeof(RewardInstructionRecord)); static_assert(sizeof(SeasonPassPackageRecord) == (1 + season_pass::kPackageItemCapacity) * sizeof(std::uint32_t) + 4 * sizeof(std::uint8_t)); diff --git a/Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp b/Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp index 0632a9edf..0042c457e 100644 --- a/Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp +++ b/Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp @@ -49,7 +49,13 @@ namespace { && domains.progressionSteps.size() <= kMaximumCount && domains.seasonPassRewards.size() <= kMaximumCount && domains.seasonPassPackages.size() <= kMaximumCount - && domains.bounties.size() <= kMaximumCount; + && domains.bounties.size() <= kMaximumCount + && domains.rewardPools.size() <= kMaximumCount + && domains.rewardEntries.size() <= kMaximumCount + && domains.rewardItems.size() <= kMaximumCount + && domains.rewardInstructions.size() <= kMaximumCount + && domains.rewardModifiers.size() <= kMaximumCount + && domains.rewardSockets.size() <= kMaximumCount; } /** @return True when the requested final-name rule is one of the declared values. */ diff --git a/Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp b/Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp index 0c4b610fd..7ab2e9f8b 100644 --- a/Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp +++ b/Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp @@ -103,7 +103,14 @@ bool payload_checksum(records::Domains domains, std::uint64_t& checksum) noexcep && checksum_domain(domains.seasonPassRewards, checksum) && checksum_domain(domains.seasonPassPackages, checksum) - && checksum_domain(domains.bounties, checksum); + && checksum_domain(domains.bounties, checksum) + && checksum_domain(domains.rewardPools, checksum) + && checksum_domain(domains.rewardEntries, checksum) + && checksum_domain(domains.rewardItems, checksum) + && checksum_domain(domains.rewardInstructions, + checksum) + && checksum_domain(domains.rewardModifiers, checksum) + && checksum_domain(domains.rewardSockets, checksum); } /** Writes every array in the same order used by the payload checksum. */ @@ -144,7 +151,13 @@ bool write_payload(HANDLE file, records::Domains domains) noexcept { && write_domain(file, domains.progressionSteps) && write_domain(file, domains.seasonPassRewards) && write_domain(file, domains.seasonPassPackages) - && write_domain(file, domains.bounties); + && write_domain(file, domains.bounties) + && write_domain(file, domains.rewardPools) + && write_domain(file, domains.rewardEntries) + && write_domain(file, domains.rewardItems) + && write_domain(file, domains.rewardInstructions) + && write_domain(file, domains.rewardModifiers) + && write_domain(file, domains.rewardSockets); } } // namespace sunrise::state::build_data::cache::writer diff --git a/Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp b/Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp index 92fa274f3..dfd700d1a 100644 --- a/Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp +++ b/Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp @@ -143,6 +143,12 @@ enum class WriteStatus { static_cast(domains.seasonPassRewards.size()), static_cast(domains.seasonPassPackages.size()), static_cast(domains.bounties.size()), + static_cast(domains.rewardPools.size()), + static_cast(domains.rewardEntries.size()), + static_cast(domains.rewardItems.size()), + static_cast(domains.rewardInstructions.size()), + static_cast(domains.rewardModifiers.size()), + static_cast(domains.rewardSockets.size()), domains.positionFingerprint, domains.constants, checksum, diff --git a/Sunrise/src/state/build_data/inventory/buckets/definition.h b/Sunrise/src/state/build_data/inventory/buckets/definition.h index 92feb8979..58d0b7200 100644 --- a/Sunrise/src/state/build_data/inventory/buckets/definition.h +++ b/Sunrise/src/state/build_data/inventory/buckets/definition.h @@ -25,6 +25,11 @@ inline constexpr std::uint32_t kProfileSlotCapacity = 701; inline constexpr std::uint32_t kSmallProfileSlotCapacity = 6; /** An all-one bucket id means no runtime bucket is available. */ inline constexpr std::uint8_t kUnavailableBucketId = (std::numeric_limits::max)(); +/** + * Season Pass perks share this 5-slot character bucket with quest roots. A reward item here that + * carries an acquisition flag is granted by setting that flag rather than as an inventory row. + */ +inline constexpr std::uint8_t kPerkBucketId = 37; /** Leaving out the unavailable id leaves at most 255 unique bucket records. */ inline constexpr std::size_t kDescriptorCapacity = kUnavailableBucketId; /** Signed -1 marks a bucket that has no equipment slot. */ diff --git a/Sunrise/src/state/build_data/progressions/definition.h b/Sunrise/src/state/build_data/progressions/definition.h index 9d332f2f8..60daa2f8c 100644 --- a/Sunrise/src/state/build_data/progressions/definition.h +++ b/Sunrise/src/state/build_data/progressions/definition.h @@ -41,6 +41,8 @@ struct Definition { std::uint16_t stepOffset{}; std::uint8_t stepCount{}; Scope scope{Scope::unreplicated}; + /** Installed progression row +5 decides whether the final rank step repeats. */ + bool repeatLastStep{}; }; } // namespace sunrise::state::build_data::progressions diff --git a/Sunrise/src/state/build_data/progressions/progression_catalog.cpp b/Sunrise/src/state/build_data/progressions/progression_catalog.cpp index c269ab91b..025fe1b9c 100644 --- a/Sunrise/src/state/build_data/progressions/progression_catalog.cpp +++ b/Sunrise/src/state/build_data/progressions/progression_catalog.cpp @@ -56,6 +56,23 @@ bool replace(std::span definitions, std::span step return replaced; } +/** + * Reads one definition under the catalog lock. + * @param definitionIndex Native progression definition index. + * @param definition Receives the row, or an empty row when absent. + * @return False when the catalog does not hold the index. + */ +bool find(std::uint16_t definitionIndex, Definition& definition) noexcept { + definition = {}; + const std::shared_lock guard(g_lock); + const auto rows = g_definitions.rows(); + if (definitionIndex >= rows.size()) { + return false; + } + definition = rows[definitionIndex]; + return true; +} + /** Copies the rank steps one progression declares, in rank order. */ bool steps(std::uint16_t definitionIndex, std::span output, std::size_t& count) noexcept { count = 0; diff --git a/Sunrise/src/state/build_data/progressions/progression_catalog.h b/Sunrise/src/state/build_data/progressions/progression_catalog.h index 2cc84894a..2a38905c3 100644 --- a/Sunrise/src/state/build_data/progressions/progression_catalog.h +++ b/Sunrise/src/state/build_data/progressions/progression_catalog.h @@ -29,6 +29,14 @@ void clear() noexcept; [[nodiscard]] bool replace(std::span definitions, std::span steps) noexcept; +/** + * Reads one definition by its native index. + * @param definitionIndex Native progression definition index. + * @param definition Receives the definition on success. + * @return False when the catalog does not hold the index. + */ +[[nodiscard]] bool find(std::uint16_t definitionIndex, Definition& definition) noexcept; + /** * Copies the rank steps one progression declares, in rank order. * @param definitionIndex Native progression definition index. diff --git a/Sunrise/src/state/build_data/rewards/definition.h b/Sunrise/src/state/build_data/rewards/definition.h new file mode 100644 index 000000000..3c5ebbf24 --- /dev/null +++ b/Sunrise/src/state/build_data/rewards/definition.h @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include + +#include "../../unlocks/unlocks_expression.h" +#include "../items/details/definition.h" +#include "../items/item_catalog.h" + +namespace sunrise::state::build_data::rewards { + +/** Native references use all bits set for an absent row. */ +inline constexpr std::uint16_t kAbsent = 0xFFFF; +/** The shipped build declares 979 pools. The domain leaves room above that. */ +inline constexpr std::size_t kPoolCapacity = 4096; +/** Item rows are dense over the installed item-definition table. */ +inline constexpr std::size_t kItemCapacity = items::kDefinitionCapacity; +/** The shipped build publishes 10,549 entries. The bank leaves room above that. */ +inline constexpr std::size_t kEntryCapacity = 16384; +/** The shipped build publishes 14,185 expanded condition instructions, with room above that. */ +inline constexpr std::size_t kInstructionCapacity = 16384; +/** The shipped build publishes 3,714 modifiers. The bank leaves room above that. */ +inline constexpr std::size_t kModifierCapacity = 4096; +/** The shipped build publishes 1,812 socket overrides. The bank leaves room above that. */ +inline constexpr std::size_t kSocketOverrideCapacity = 2048; +/** Reward overrides address the ordinary socket lanes an item's initial plugs fill. */ +inline constexpr std::size_t kSocketsPerItem = items::details::kInitialPlugCapacity; +/** Category selections one wrapper declares. The most any shipped wrapper declares is four. */ +inline constexpr std::size_t kSelectionCapacity = 4; +/** Grant rows one resolution may prepare: both change lists in full; perk rows use no list. */ +inline constexpr std::size_t kGrantCapacity = 32; +/** + * Nested pools, automatic wrappers and referenced conditions share one traversal depth. The + * installed graph reaches 7. + */ +inline constexpr std::size_t kTraversalDepth = 32; + +struct Range { + std::uint32_t first{}; + std::uint32_t count{}; +}; + +/** Reward and pass conditions are unlock expressions bound to their saved banks. */ +using Instruction = unlocks::Instruction; + +/** A fixed override names its plug directly rather than selecting from a set. */ +inline constexpr std::uint32_t kFixedPlugSelection = 0xFFFFFFFFU; +/** Wrappers without this bit remain inventory items until explicitly opened. */ +inline constexpr std::uint32_t kOpenOnAcquisition = 1; + +struct SocketOverride { + std::uint16_t socketType{kAbsent}; + std::uint16_t plugItem{kAbsent}; + std::uint16_t plugSet{kAbsent}; + std::uint16_t rollSet{kAbsent}; + std::uint32_t selection{kFixedPlugSelection}; +}; + +struct Modifier { + Range condition{}; + std::uint16_t valueIndex{kAbsent}; + float value{}; +}; + +struct Entry { + std::uint16_t itemIndex{kAbsent}; + std::uint16_t poolIndex{kAbsent}; + /** Native entry +10 selects a supplemental reward, not an item or nested pool. */ + std::uint16_t supplementalIndex{kAbsent}; + /** True only when the source bank is explicitly absent from the investment root. */ + bool supplementalMissing{}; + std::uint32_t quantity{}; + std::uint32_t categoryHash{}; + float weight{}; + Range condition{}; + Range modifiers{}; + Range sockets{}; +}; + +struct Pool { + std::uint32_t definitionHash{}; + Range entries{}; +}; + +struct Selection { + std::uint32_t categoryHash{}; + std::uint32_t count{}; +}; + +/** Dense item rows bind inventory definitions to their reward pools and acquisition flags. */ +struct Item { + std::uint32_t definitionHash{}; + std::uint16_t poolIndex{kAbsent}; + std::uint16_t acquiredFlag{kAbsent}; + std::array selections{}; + std::uint8_t selectionCount{}; + std::uint32_t flags{}; +}; + +struct View { + std::span pools; + std::span entries; + std::span items; + std::span instructions; + std::span modifiers; + std::span sockets; +}; + +template [[nodiscard]] constexpr bool fits(Range range, std::span bank) noexcept { + return range.first <= bank.size() && range.count <= bank.size() - range.first; +} + +/** An override names a socket type and, when it fixes a plug, an item that was read. */ +[[nodiscard]] constexpr bool valid_socket(const SocketOverride& socket, + std::size_t itemCount) noexcept { + return socket.socketType != kAbsent + && (socket.plugItem == kAbsent || socket.plugItem < itemCount); +} + +} // namespace sunrise::state::build_data::rewards diff --git a/Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp b/Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp new file mode 100644 index 000000000..4621b139c --- /dev/null +++ b/Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp @@ -0,0 +1,35 @@ +#include "../runtime.h" +#include "../runtime/persistence/publication_transaction.h" +#include "reward_catalog.h" + +namespace sunrise::state::build_data { + +/** @return True when the reward pools and item wrappers are published. */ +bool reward_definitions_ready() noexcept { + return rewards::ready(); +} + +/** Publishes the complete reward graph in one step. */ +bool publish_reward_definitions(rewards::View definitions) noexcept { + runtime::persistence::Transaction transaction; + return transaction.active() + && transaction.finish(rewards::replace(definitions), rewards::clear); +} + +/** Checks a reward graph against the catalog's publication rules. */ +bool valid_reward_definitions(rewards::View definitions) noexcept { + return rewards::valid(definitions); +} + +/** Reads one item's reward row. */ +bool find_reward_item(std::uint16_t itemIndex, rewards::Item& item) noexcept { + return rewards::find_item(itemIndex, item); +} + +/** Lends the complete reward graph to one callback under the catalog's read lock. */ +bool read_reward_definitions(void* context, + bool (*consume)(void*, rewards::View) noexcept) noexcept { + return rewards::read(context, consume); +} + +} // namespace sunrise::state::build_data diff --git a/Sunrise/src/state/build_data/rewards/reward_catalog.cpp b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp new file mode 100644 index 000000000..3eea25f89 --- /dev/null +++ b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp @@ -0,0 +1,221 @@ +#include "reward_catalog.h" + +#include +#include +#include +#include +#include + +#include "../../unlocks/definition.h" +#include "../table.h" +#include "core/threading/srw_lock.h" + +namespace sunrise::state::build_data::rewards { +namespace { + +core::threading::SrwLock g_lock; +Table g_pools; +Table g_entries; +Table g_items; +Table g_instructions; +Table g_modifiers; +Table g_sockets; + +/** Borrows every published bank; the caller holds the catalog lock. */ +View view() noexcept { + return {g_pools.rows(), + g_entries.rows(), + g_items.rows(), + g_instructions.rows(), + g_modifiers.rows(), + g_sockets.rows()}; +} + +/** Rejects cycles and bounds combined pool and automatic-wrapper paths, memoizing heights. */ +bool valid_depth(View data, + std::size_t index, + std::array& heights, + std::size_t depth = 0) noexcept { + constexpr std::uint8_t kVisiting = 0xFF; + if (depth >= kTraversalDepth) { + return false; + } + if (heights[index] != 0) { + // Reused subtrees must also fit when reached through a deeper path. + return heights[index] != kVisiting && heights[index] <= kTraversalDepth - depth; + } + heights[index] = kVisiting; + std::uint8_t height = 1; + const Range range = data.pools[index].entries; + for (const Entry& entry : data.entries.subspan(range.first, range.count)) { + auto child = entry.poolIndex; + // Pool references take precedence over an item named by the same entry. + if (child == kAbsent && entry.itemIndex != kAbsent) { + const Item& item = data.items[entry.itemIndex]; + if ((item.flags & kOpenOnAcquisition) != 0) { + child = item.poolIndex; + } + } + if (child == kAbsent) { + continue; + } + if (!valid_depth(data, child, heights, depth + 1)) { + return false; + } + height = (std::max)(height, static_cast(heights[child] + 1)); + } + heights[index] = height; + return true; +} + +/** Checks one entry's references, dependent ranges and weight. */ +bool valid_entry(View data, const Entry& entry) noexcept { + return (entry.itemIndex == kAbsent || entry.itemIndex < data.items.size()) + && (entry.poolIndex == kAbsent || entry.poolIndex < data.pools.size()) + && (!entry.supplementalMissing || entry.supplementalIndex != kAbsent) + && fits(entry.condition, data.instructions) && fits(entry.modifiers, data.modifiers) + && fits(entry.sockets, data.sockets) && std::isfinite(entry.weight) && entry.weight >= 0; +} + +/** An unavailable item row carries nothing; an available one names valid banks. */ +bool valid_item(View data, const Item& item) noexcept { + if (item.definitionHash == 0) { + return item.poolIndex == kAbsent && item.acquiredFlag == kAbsent + && item.selectionCount == 0; + } + return item.selectionCount <= item.selections.size() + && (item.acquiredFlag == kAbsent || item.acquiredFlag < unlocks::kAccountFlagCapacity) + && (item.poolIndex == kAbsent || item.poolIndex < data.pools.size()); +} + +} // namespace + +/** Discards the reward graph and every borrowed bank. */ +void clear() noexcept { + const std::lock_guard guard(g_lock); + g_pools.clear(); + g_entries.clear(); + g_items.clear(); + g_instructions.clear(); + g_modifiers.clear(); + g_sockets.clear(); +} + +/** @return True once pool and item banks have been published. */ +bool ready() noexcept { + const std::shared_lock guard(g_lock); + return g_pools.count() != 0 && g_items.count() != 0; +} + +/** Checks bank ranges, item references and bounded acyclic pool and wrapper traversal. */ +bool valid(View data) noexcept { + if (data.pools.empty() || data.pools.size() > kPoolCapacity || data.entries.empty() + || data.entries.size() > kEntryCapacity || data.items.empty() + || data.items.size() > kItemCapacity || data.instructions.size() > kInstructionCapacity + || data.modifiers.size() > kModifierCapacity + || data.sockets.size() > kSocketOverrideCapacity) { + return false; + } + for (const Pool& pool : data.pools) { + if ((pool.definitionHash == 0 && pool.entries.count != 0) + || !fits(pool.entries, data.entries)) { + return false; + } + } + for (const Entry& entry : data.entries) { + if (!valid_entry(data, entry)) { + return false; + } + } + for (const Item& item : data.items) { + if (!valid_item(data, item)) { + return false; + } + } + if (!std::all_of(data.instructions.begin(), data.instructions.end(), unlocks::valid)) { + return false; + } + for (const Modifier& modifier : data.modifiers) { + if (!fits(modifier.condition, data.instructions) || !std::isfinite(modifier.value)) { + return false; + } + } + for (const SocketOverride& socket : data.sockets) { + if (!valid_socket(socket, data.items.size())) { + return false; + } + } + std::array heights{}; + for (std::size_t index = 0; index < data.pools.size(); ++index) { + if (!valid_depth(data, index, heights)) { + return false; + } + } + return true; +} + +/** Publishes validated banks together under the catalog lock. */ +bool replace(View data) noexcept { + if (!valid(data)) { + return false; + } + const std::lock_guard guard(g_lock); + return g_pools.replace(data.pools) && g_entries.replace(data.entries) + && g_items.replace(data.items) && g_instructions.replace(data.instructions) + && g_modifiers.replace(data.modifiers) && g_sockets.replace(data.sockets); +} + +/** Lends every bank to one callback under the read lock. */ +bool read(void* context, bool (*consume)(void*, View) noexcept) noexcept { + const std::shared_lock guard(g_lock); + return consume != nullptr && g_pools.count() != 0 && consume(context, view()); +} + +/** Copies one extracted item row; unavailable rows are refused. */ +bool find_item(std::uint16_t itemIndex, Item& item) noexcept { + item = {}; + const std::shared_lock guard(g_lock); + if (itemIndex >= g_items.count() || g_items.rows()[itemIndex].definitionHash == 0) { + return false; + } + item = g_items.rows()[itemIndex]; + return true; +} + +/** Copies every pool; count is zero when the output is too small. */ +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_pools.snapshot(output, count); +} + +/** Copies every entry; count is zero when the output is too small. */ +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_entries.snapshot(output, count); +} + +/** Copies every item row; count is zero when the output is too small. */ +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_items.snapshot(output, count); +} + +/** Copies every condition instruction; count is zero when the output is too small. */ +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_instructions.snapshot(output, count); +} + +/** Copies every modifier; count is zero when the output is too small. */ +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_modifiers.snapshot(output, count); +} + +/** Copies every socket override; count is zero when the output is too small. */ +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_sockets.snapshot(output, count); +} + +} // namespace sunrise::state::build_data::rewards diff --git a/Sunrise/src/state/build_data/rewards/reward_catalog.h b/Sunrise/src/state/build_data/rewards/reward_catalog.h new file mode 100644 index 000000000..3b1a71766 --- /dev/null +++ b/Sunrise/src/state/build_data/rewards/reward_catalog.h @@ -0,0 +1,28 @@ +#pragma once + +#include "definition.h" + +namespace sunrise::state::build_data::rewards { + +/** Discards the reward graph and every borrowed bank. */ +void clear() noexcept; +/** True once pool and item banks have been published. */ +[[nodiscard]] bool ready() noexcept; +/** Checks bank ranges, item references and bounded acyclic pool and wrapper traversal. */ +[[nodiscard]] bool valid(View data) noexcept; +/** Publishes validated banks together under the catalog lock. */ +[[nodiscard]] bool replace(View data) noexcept; +/** The borrowed view remains valid only during the callback. */ +[[nodiscard]] bool read(void* context, bool (*consume)(void*, View) noexcept) noexcept; +/** Copies a native item row, refusing unavailable extraction entries. */ +[[nodiscard]] bool find_item(std::uint16_t itemIndex, Item& item) noexcept; + +/** Copies one complete bank; count is zero if the output is too small. */ +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; + +} // namespace sunrise::state::build_data::rewards diff --git a/Sunrise/src/state/build_data/runtime.h b/Sunrise/src/state/build_data/runtime.h index 29b4c83db..23691afb8 100644 --- a/Sunrise/src/state/build_data/runtime.h +++ b/Sunrise/src/state/build_data/runtime.h @@ -23,6 +23,7 @@ #include "nodes/definition.h" #include "progressions/definition.h" #include "records/definition.h" +#include "rewards/definition.h" #include "scenarios/definition.h" #include "season_pass/definition.h" #include "sobjects/sobject_catalog.h" @@ -397,7 +398,7 @@ publish_progression_definitions(std::span defini [[nodiscard]] bool season_pass_ready() noexcept; /** - * Publishes the season pass reward list and the wrapper items it grants, in one step. + * Publishes the season pass reward list with its fixed socket overrides. * @param rewards Complete reward rows in native reward order. * @param packages Complete wrapper packages. * @return True when the rows pass the checks and any needed cache write succeeds. @@ -405,6 +406,9 @@ publish_progression_definitions(std::span defini [[nodiscard]] bool publish_season_pass(std::span rewards, std::span packages) noexcept; +/** Checks rows against the rules publish_season_pass applies to the whole list. */ +[[nodiscard]] bool valid_season_pass_rewards(std::span rewards) noexcept; + /** * Reads one season pass reward row. * @param rewardIndex Native reward-array index the opcode-2400 claim names. @@ -414,6 +418,39 @@ publish_progression_definitions(std::span defini [[nodiscard]] bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& reward) noexcept; +/** @return Season pass reward rows in State, including unavailable rows. */ +[[nodiscard]] std::size_t season_pass_reward_count() noexcept; + +/** @return True when the reward pools and item wrappers are in State. */ +[[nodiscard]] bool reward_definitions_ready() noexcept; + +/** + * Publishes the complete reward graph in one step. + * @param definitions Pools, entries, item rows, conditions, modifiers and socket overrides. + * @return True when the graph passes the checks and any needed cache write succeeds. + */ +[[nodiscard]] bool publish_reward_definitions(rewards::View definitions) noexcept; + +/** Checks a reward graph against the rules publish_reward_definitions applies. */ +[[nodiscard]] bool valid_reward_definitions(rewards::View definitions) noexcept; + +/** + * Reads one item's reward row. + * @param itemIndex Native item-definition index. + * @param item Receives the row. + * @return True when the catalog holds an extracted row for that item. + */ +[[nodiscard]] bool find_reward_item(std::uint16_t itemIndex, rewards::Item& item) noexcept; + +/** + * Lends the complete reward graph to one callback under the catalog's read lock. + * @param context Caller state passed through unchanged. + * @param consume Receives the graph, which stays valid only during the call. + * @return The callback's result, or false when no graph is published. + */ +[[nodiscard]] bool read_reward_definitions(void* context, + bool (*consume)(void*, rewards::View) noexcept) noexcept; + /** * Finds the item set one season pass wrapper opens into. * @param definitionHash Authored wrapper item hash. diff --git a/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp b/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp index 95946bf31..203bb8376 100644 --- a/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp +++ b/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp @@ -14,6 +14,7 @@ #include "../nodes/node_catalog.h" #include "../progressions/progression_catalog.h" #include "../records/record_catalog.h" +#include "../rewards/reward_catalog.h" #include "../runtime.h" #include "../scenarios/scenario_catalog.h" #include "../season_pass/season_pass_catalog.h" @@ -21,6 +22,7 @@ #include "../socket_entry_buckets/socket_entry_bucket_catalog.h" #include "../socket_entry_lists/socket_entry_list_catalog.h" #include "../spawn_sets/spawn_set_catalog.h" +#include "../vendors/reputation_sale_catalog.h" #include "../vendors/vendor_catalog.h" #include "domain_markers.h" #include "persistence/publication_transaction.h" @@ -372,6 +374,7 @@ void clear_catalogs() noexcept { rollback_ability_publication(); progressions::clear(); season_pass::clear(); + rewards::clear(); bounties::clear(); records::clear(); nodes::clear(); @@ -380,6 +383,7 @@ void clear_catalogs() noexcept { rollback_spawn_catalog_publication(); rollback_name_catalog_publication(); vendors::clear(); + vendors::clear_reputation_sales(); constants::clear(); } diff --git a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp index a02ffb60d..3cc29f313 100644 --- a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp +++ b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp @@ -21,6 +21,7 @@ #include "../../nodes/node_catalog.h" #include "../../progressions/progression_catalog.h" #include "../../records/record_catalog.h" +#include "../../rewards/reward_catalog.h" #include "../../runtime.h" #include "../../scenarios/scenario_catalog.h" #include "../../season_pass/season_pass_catalog.h" @@ -92,6 +93,12 @@ to_record(const constants::InvestmentConstants& value) noexcept { && season_pass::snapshot(scratch.seasonPassRewards, counts.seasonPassRewards) && season_pass::snapshot_packages(scratch.seasonPassPackages, counts.seasonPassPackages) && bounties::snapshot(scratch.bounties, counts.bounties) + && rewards::snapshot(scratch.rewardPools, counts.rewardPools) + && rewards::snapshot(scratch.rewardEntries, counts.rewardEntries) + && rewards::snapshot(scratch.rewardItems, counts.rewardItems) + && rewards::snapshot(scratch.rewardInstructions, counts.rewardInstructions) + && rewards::snapshot(scratch.rewardModifiers, counts.rewardModifiers) + && rewards::snapshot(scratch.rewardSockets, counts.rewardSockets) && records::snapshot(scratch.records, counts.records) && records::snapshot_objectives(scratch.recordObjectives, counts.recordObjectives) && records::snapshot_intervals(scratch.recordIntervals, counts.recordIntervals) @@ -125,9 +132,10 @@ to_record(const constants::InvestmentConstants& value) noexcept { && material_requirement_sets_ready() && inventory_bucket_descriptors_ready() && socket_entry_lists_ready() && ability_buckets_ready() && progression_definitions_ready() && season_pass_ready() && repeatable_bounties_ready() - && record_definitions_ready() && node_definitions_ready() && sobject_definitions_ready() - && scenario_layouts_ready() && spawn_sets_ready() && hash_names_ready() - && vendor_catalog_ready() && gameplay::entity_position_profiles::available() + && reward_definitions_ready() && record_definitions_ready() && node_definitions_ready() + && sobject_definitions_ready() && scenario_layouts_ready() && spawn_sets_ready() + && hash_names_ready() && vendor_catalog_ready() + && gameplay::entity_position_profiles::available() && gameplay::entity_object_types::available() && constants::find(published); } @@ -270,6 +278,14 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept { seasonPassRewards, seasonPassPackages, bountyRows, + ensure_scratch(state.rewardPoolScratch), + ensure_scratch(state.rewardEntryScratch), + ensure_scratch(state.rewardItemScratch), + ensure_scratch( + state.rewardInstructionScratch), + ensure_scratch(state.rewardModifierScratch), + ensure_scratch( + state.rewardSocketScratch), }; } @@ -301,6 +317,12 @@ void release_scratch_locked(Context& state) noexcept { release_bank(state.seasonPassRewardScratch); release_bank(state.seasonPassPackageScratch); release_bank(state.bountyScratch); + release_bank(state.rewardPoolScratch); + release_bank(state.rewardEntryScratch); + release_bank(state.rewardItemScratch); + release_bank(state.rewardInstructionScratch); + release_bank(state.rewardModifierScratch); + release_bank(state.rewardSocketScratch); release_bank(state.recordScratch); release_bank(state.recordObjectiveScratch); release_bank(state.recordIntervalScratch); @@ -405,6 +427,15 @@ cache::records::Domains occupied_domains(Context& state, std::span{state.seasonPassPackageScratch.data(), counts.seasonPassPackages}, std::span{state.bountyScratch.data(), counts.bounties}, + std::span{state.rewardPoolScratch.data(), counts.rewardPools}, + std::span{state.rewardEntryScratch.data(), counts.rewardEntries}, + std::span{state.rewardItemScratch.data(), counts.rewardItems}, + std::span{state.rewardInstructionScratch.data(), + counts.rewardInstructions}, + std::span{state.rewardModifierScratch.data(), + counts.rewardModifiers}, + std::span{state.rewardSocketScratch.data(), + counts.rewardSockets}, }; } diff --git a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h index da466ba7d..4cb31a282 100644 --- a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h +++ b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h @@ -81,6 +81,12 @@ struct Context { std::vector seasonPassRewardScratch{}; std::vector seasonPassPackageScratch{}; std::vector bountyScratch{}; + std::vector rewardPoolScratch{}; + std::vector rewardEntryScratch{}; + std::vector rewardItemScratch{}; + std::vector rewardInstructionScratch{}; + std::vector rewardModifierScratch{}; + std::vector rewardSocketScratch{}; std::vector recordScratch{}; std::vector recordObjectiveScratch{}; std::vector recordIntervalScratch{}; diff --git a/Sunrise/src/state/build_data/season_pass/definition.h b/Sunrise/src/state/build_data/season_pass/definition.h index fb15fb7b8..17a1cad9e 100644 --- a/Sunrise/src/state/build_data/season_pass/definition.h +++ b/Sunrise/src/state/build_data/season_pass/definition.h @@ -4,6 +4,8 @@ #include #include +#include "../rewards/definition.h" + namespace sunrise::state::build_data::season_pass { /** Reward rows the installed pass declares. The shipped build carries 196. */ @@ -18,9 +20,12 @@ inline constexpr std::size_t kPackageItemCapacity = 8; /** A reward whose claim flag no mapping table addresses carries this instead of an index. */ inline constexpr std::uint16_t kUnavailableFlagIndex = 0xFFFFU; +/** Instructions in one row's expanded eligibility condition. The longest shipped row needs 5. */ +inline constexpr std::size_t kConditionCapacity = 16; + /** One reward row of the pass, in the native order the opcode-2400 claim names by index. */ struct Reward { - /** Authored definition hash of the granted item. */ + /** Installed definition hash of the granted item. */ std::uint32_t itemHash{}; /** Units granted. */ std::uint32_t quantity{}; @@ -30,6 +35,12 @@ struct Reward { std::uint16_t claimFlagIndex{kUnavailableFlagIndex}; /** Rank the account needs before the row may be claimed. */ std::uint8_t requiredRank{}; + /** Fixed plugs this row places on the granted item, in native order. */ + std::array sockets{}; + std::uint8_t socketCount{}; + /** Bound eligibility condition; every native expression on the row must hold. */ + std::array condition{}; + std::uint8_t conditionCount{}; }; /** One wrapper reward and the items it opens into. */ @@ -38,6 +49,7 @@ struct Package { std::uint32_t definitionHash{}; /** Authored definition hashes the wrapper opens into, in declared order. */ std::array items{}; + /** Number of populated entries at the start of items. */ std::uint8_t itemCount{}; }; diff --git a/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp b/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp index 6b2e82d58..4c7715805 100644 --- a/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp +++ b/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp @@ -9,7 +9,7 @@ bool season_pass_ready() noexcept { return season_pass::count() != 0; } -/** Publishes the season pass reward list and the wrapper items it grants, in one step. */ +/** Publishes the season pass reward list with its fixed socket overrides and wrapper packages. */ bool publish_season_pass(std::span rewards, std::span packages) noexcept { runtime::persistence::Transaction transaction; @@ -17,11 +17,21 @@ bool publish_season_pass(std::span rewards, && transaction.finish(season_pass::replace(rewards, packages), season_pass::clear); } +/** Checks season pass rows against the catalog's publication rules. */ +bool valid_season_pass_rewards(std::span rewards) noexcept { + return season_pass::valid(rewards, {}); +} + /** Reads one season pass reward row. */ bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& reward) noexcept { return season_pass::find(rewardIndex, reward); } +/** @return Season pass reward rows in State. */ +std::size_t season_pass_reward_count() noexcept { + return season_pass::count(); +} + /** Finds the item set one season pass wrapper opens into. */ bool find_season_pass_package(std::uint32_t definitionHash, season_pass::Package& package) noexcept { diff --git a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp index f074dc55b..ae963f653 100644 --- a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp +++ b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp @@ -1,42 +1,60 @@ #include "season_pass_catalog.h" #include +#include +#include #include +#include "../../unlocks/definition.h" +#include "../../unlocks/unlocks_expression.h" #include "../table.h" #include "core/threading/srw_lock.h" namespace sunrise::state::build_data::season_pass { namespace { - -// One lock covers both tables. A reward names a wrapper by hash, so a reader must never see one -// table replaced and the other not. core::threading::SrwLock g_lock; Table g_rewards; Table g_packages; - } // namespace -/** Clears the reward rows and the wrapper packages under the catalog lock. */ +/** Clears the reward rows and wrapper packages under the catalog lock. */ void clear() noexcept { const std::lock_guard guard(g_lock); g_rewards.clear(); g_packages.clear(); } -/** Checks one complete pass catalog. */ +/** Checks one complete pass catalog; unavailable rows keep their native positions. */ bool valid(std::span rewards, std::span packages) noexcept { if (rewards.empty() || rewards.size() > kRewardCapacity || packages.size() > kPackageCapacity) { return false; } - // A row granting nothing would claim its flag and hand the account no item. - const bool granting = std::all_of(rewards.begin(), rewards.end(), [](const Reward& reward) { - return reward.itemHash != 0 && reward.quantity != 0; + constexpr auto kMaximumQuantity = + static_cast((std::numeric_limits::max)()); + for (const Reward& reward : rewards) { + // An unavailable row keeps its native claim index and grants nothing. + if (reward.itemHash == 0) { + if (reward.quantity != 0 || reward.conditionCount != 0 || reward.socketCount != 0 + || reward.claimFlagIndex != kUnavailableFlagIndex) { + return false; + } + continue; + } + if (reward.quantity == 0 || reward.quantity > kMaximumQuantity + || (reward.claimFlagIndex != kUnavailableFlagIndex + && reward.claimFlagIndex >= unlocks::kAccountFlagCapacity) + || reward.socketCount > reward.sockets.size() + || reward.conditionCount > reward.condition.size() + || !std::all_of(reward.condition.begin(), + reward.condition.begin() + reward.conditionCount, + unlocks::valid)) { + return false; + } + } + return std::all_of(packages.begin(), packages.end(), [](const Package& package) { + return package.definitionHash != 0 && package.itemCount != 0 + && package.itemCount <= kPackageItemCapacity; }); - return granting && std::all_of(packages.begin(), packages.end(), [](const Package& package) { - return package.definitionHash != 0 && package.itemCount != 0 - && package.itemCount <= kPackageItemCapacity; - }); } /** Replaces the complete pass catalog in one step. */ @@ -53,15 +71,14 @@ bool replace(std::span rewards, std::span packages) return replaced; } -/** Reads one reward row by the index an opcode-2400 claim names. */ +/** Reads one available reward row by the index an opcode-2400 claim names. */ bool find(std::uint16_t rewardIndex, Reward& reward) noexcept { reward = {}; const std::shared_lock guard(g_lock); - const std::span rows = g_rewards.rows(); - if (rewardIndex >= rows.size()) { + if (rewardIndex >= g_rewards.count() || g_rewards.rows()[rewardIndex].itemHash == 0) { return false; } - reward = rows[rewardIndex]; + reward = g_rewards.rows()[rewardIndex]; return true; } @@ -90,7 +107,7 @@ bool snapshot_packages(std::span output, std::size_t& count) noexcept { return g_packages.snapshot(output, count); } -/** @return The reward row count, read under the lock. */ +/** @return The reward row count, including unavailable rows, read under the lock. */ std::size_t count() noexcept { const std::shared_lock guard(g_lock); return g_rewards.count(); diff --git a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.h b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.h index 43af4275d..1bc345136 100644 --- a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.h +++ b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.h @@ -1,67 +1,65 @@ #pragma once -#include -#include #include #include "definition.h" namespace sunrise::state::build_data::season_pass { -/** Clears the reward rows and the wrapper packages. */ +/** Discards the installed pass, including its native claim indices and wrapper packages. */ void clear() noexcept; /** - * Checks one complete pass catalog. - * @param rewards Candidate reward rows in native reward order. - * @param packages Candidate wrapper packages, which may be empty. - * @return True when every count and item range holds. + * Checks one complete pass catalog without publishing it. + * @param rewards Reward rows in native claim order; unavailable rows retain their positions. + * @param packages Wrapper packages referenced by reward items. + * @return True when both banks fit fixed storage and every populated row is valid. */ [[nodiscard]] bool valid(std::span rewards, std::span packages) noexcept; /** - * Replaces the complete pass catalog in one step. - * @param rewards Complete reward rows in native reward order. + * Replaces the complete pass catalog without reordering native claim indices. + * @param rewards Complete reward rows in native claim order. * @param packages Complete wrapper packages. - * @return True when the catalog passes validation and fits fixed State storage. + * @return True when both banks validate and publish together. */ [[nodiscard]] bool replace(std::span rewards, std::span packages) noexcept; /** - * Reads one reward row by the index an opcode-2400 claim names. - * @param rewardIndex Native reward-array index. - * @param reward Receives the row. - * @return True when the catalog holds that row. + * Copies one available native claim row. + * @param rewardIndex Native claim-row index. + * @param reward Receives the row, or is cleared when absent or unavailable. + * @return True when the index names an available row. */ [[nodiscard]] bool find(std::uint16_t rewardIndex, Reward& reward) noexcept; /** * Finds the wrapper package one item hash opens into. * @param definitionHash Authored wrapper item hash. - * @param package Receives the matching wrapper. - * @return True when a wrapper carries that hash. + * @param package Receives the package, or is cleared when absent. + * @return True when the catalog contains that wrapper. */ [[nodiscard]] bool find_package(std::uint32_t definitionHash, Package& package) noexcept; /** - * Copies every reward row in native reward order. - * @param output Caller-owned fixed row storage. + * Copies the complete pass in native claim order. + * @param output Caller-owned reward storage. * @param count Receives the copied row count, or zero when output is too small. - * @return True when output can hold every row. + * @return True when output can hold every reward row. */ [[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; /** - * Copies every wrapper package in declared order. - * @param output Caller-owned fixed row storage. - * @param count Receives the copied row count, or zero when output is too small. - * @return True when output can hold every row. + * Copies every wrapper package in publication order. + * @param output Caller-owned package storage. + * @param count Receives the copied package count, or zero when output is too small. + * @return True when output can hold every package. */ [[nodiscard]] bool snapshot_packages(std::span output, std::size_t& count) noexcept; -/** @return The reward row count, read under the lock. */ +/** @return Number of native claim positions, including unavailable rows. */ [[nodiscard]] std::size_t count() noexcept; } // namespace sunrise::state::build_data::season_pass diff --git a/Sunrise/src/state/build_data/vendors/definition.h b/Sunrise/src/state/build_data/vendors/definition.h index db3be28ff..ac2cc3dab 100644 --- a/Sunrise/src/state/build_data/vendors/definition.h +++ b/Sunrise/src/state/build_data/vendors/definition.h @@ -28,6 +28,8 @@ inline constexpr std::size_t kThirdRowStride = 80; inline constexpr std::size_t kSaleCostRowStride = 48; /** Element class of a sale row's cost array. */ inline constexpr std::uint32_t kSaleCostRowClass = 0x80807865U; +/** A vendor without a resolved faction has no progression index to award. */ +inline constexpr std::uint16_t kUnavailableFactionProgressionIndex = 0xFFFFU; /** Wrapper class of the vendor index blob. */ inline constexpr std::uint32_t kIndexWrapperClass = 0x8080784AU; @@ -83,6 +85,12 @@ struct Definition { std::uint16_t installedCount{}; std::uint16_t saleCount{}; std::uint16_t thirdCount{}; + /** Raw vendor definition +18; some vendors carry an out-of-range no-faction value. */ + std::uint16_t factionIndexRaw{}; + /** Faction definition hash resolved through `factionIndexRaw`, or zero when unresolved. */ + std::uint32_t factionHash{}; + /** Native faction row +4; unavailable when the vendor has no faction row. */ + std::uint16_t factionProgressionIndex{kUnavailableFactionProgressionIndex}; }; /** A cost entry naming no item carries this. */ diff --git a/Sunrise/src/state/build_data/vendors/reconstructed_rank_claim_links.h b/Sunrise/src/state/build_data/vendors/reconstructed_rank_claim_links.h new file mode 100644 index 000000000..9edd00ff6 --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/reconstructed_rank_claim_links.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include + +namespace sunrise::state::build_data::vendors { + +/** Build-86657 rank credit links also supply sales omitted by rowless replies. */ +struct ReconstructedRankClaimLink { + std::uint32_t vendorHash; + std::uint16_t interactionIndex; + std::int32_t categoryIndex; + std::uint16_t saleIndex; +}; + +/** Build-86657 Commander Zavala vendor definition. */ +inline constexpr std::uint32_t kVanguardRankVendorHash = 69482069U; +/** Zavala's normal claim interaction, excluding the adjacent level warning. */ +inline constexpr std::uint16_t kVanguardRankInteraction = 40; +/** Vendor-local category containing Zavala's rank packages. */ +inline constexpr std::int32_t kVanguardRankCategory = 3; +/** Reconstructed rowless selection of Zavala's current package sale. */ +inline constexpr std::uint16_t kVanguardRankSale = 93; + +/** Build-86657 Lord Shaxx vendor definition. */ +inline constexpr std::uint32_t kCrucibleRankVendorHash = 3603221665U; +/** Shaxx's normal claim interaction, excluding the adjacent level warning. */ +inline constexpr std::uint16_t kCrucibleRankInteraction = 28; +/** Vendor-local category containing Shaxx's rank packages. */ +inline constexpr std::int32_t kCrucibleRankCategory = 10; +/** Reconstructed rowless selection of Shaxx's current package sale. */ +inline constexpr std::uint16_t kCrucibleRankSale = 96; + +/** Build-86657 Banshee-44 vendor definition. */ +inline constexpr std::uint32_t kGunsmithRankVendorHash = 672118013U; +/** Banshee's normal claim interaction, excluding the adjacent level warning. */ +inline constexpr std::uint16_t kGunsmithRankInteraction = 35; +/** Vendor-local category containing Banshee's rank packages. */ +inline constexpr std::int32_t kGunsmithRankCategory = 8; +/** Reconstructed rowless selection of Banshee's current package sale. */ +inline constexpr std::uint16_t kGunsmithRankSale = 16; + +inline constexpr std::array kReconstructedRankClaimLinks{ + ReconstructedRankClaimLink{kVanguardRankVendorHash, + kVanguardRankInteraction, + kVanguardRankCategory, + kVanguardRankSale}, + ReconstructedRankClaimLink{kCrucibleRankVendorHash, + kCrucibleRankInteraction, + kCrucibleRankCategory, + kCrucibleRankSale}, + ReconstructedRankClaimLink{kGunsmithRankVendorHash, + kGunsmithRankInteraction, + kGunsmithRankCategory, + kGunsmithRankSale}, +}; + +/** + * Finds the reconstructed claim link for one installed vendor. + * @param vendorHash Installed vendor definition hash. + * @return The link, or null for vendors without a supported rank claim. + */ +[[nodiscard]] constexpr const ReconstructedRankClaimLink* +find_reconstructed_rank_claim_link(std::uint32_t vendorHash) noexcept { + for (const ReconstructedRankClaimLink& link : kReconstructedRankClaimLinks) { + if (link.vendorHash == vendorHash) { + return &link; + } + } + return nullptr; +} + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.cpp b/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.cpp new file mode 100644 index 000000000..74d22d024 --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.cpp @@ -0,0 +1,198 @@ +#include "reputation_sale_catalog.h" + +#include + +#include +#include +#include +#include +#include + +#include "../../../../resources/resource.h" +#include "../../../core/threading/srw_lock.h" +#include "sqlite3.h" + +namespace sunrise::state::build_data::vendors { +namespace { + +core::threading::SrwLock g_lock; +std::array g_sales{}; +std::size_t g_saleCount{}; + +/** + * Copy the DLL's SQL resource into a terminated buffer for SQLite. + * @param module DLL holding the content resource. + * @param sql Receives SQL; cleared on failure. + * @return False when the resource is missing or empty. + */ +bool resource_sql(void* module, std::string& sql) noexcept { + sql.clear(); + const auto loaded = static_cast(module); + if (loaded == nullptr) { + return false; + } + const HRSRC found = + FindResourceW(loaded, MAKEINTRESOURCEW(IDR_VENDOR_REPUTATION_CONTENT), RT_RCDATA); + if (found == nullptr) { + return false; + } + const DWORD size = SizeofResource(loaded, found); + const HGLOBAL data = LoadResource(loaded, found); + const auto* bytes = data != nullptr ? static_cast(LockResource(data)) : nullptr; + if (size == 0 || bytes == nullptr) { + return false; + } + sql.assign(bytes, size); + return true; +} + +/** + * Reject SQLite integers outside the destination type's range. + * @tparam Value Integral destination type. + * @param statement Active row statement. + * @param index Selected column. + * @param output Receives the value on success. + * @param minimum Smallest allowed value. + * @return False for a null, non-integer or out-of-range value. + */ +template +bool column(sqlite3_stmt* statement, int index, Value& output, std::int64_t minimum) noexcept { + if (sqlite3_column_type(statement, index) != SQLITE_INTEGER) { + return false; + } + const auto value = sqlite3_column_int64(statement, index); + if (value < minimum || value > (std::numeric_limits::max)()) { + return false; + } + output = static_cast(value); + return true; +} + +/** + * Accept only a nonempty, sorted, bounded set of complete sale rows. + * @param database Temporary content database. + * @param sales Receives rows; never published on failure. + * @param count Receives the number of rows read. + * @return False when any row or the query is invalid. + */ +bool read_sales(sqlite3* database, + std::array& sales, + std::size_t& count) noexcept { + count = 0; + constexpr char kQuery[] = + "SELECT vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash, " + "category_index, cost_quantity, xp_per_unit FROM reputation_sales " + "ORDER BY vendor_hash, sale_index"; + sqlite3_stmt* statement = nullptr; + if (sqlite3_prepare_v2(database, kQuery, -1, &statement, nullptr) != SQLITE_OK) { + return false; + } + int status = SQLITE_OK; + bool valid = true; + while ((status = sqlite3_step(statement)) == SQLITE_ROW) { + if (count == sales.size()) { + valid = false; + break; + } + ReputationSale sale{}; + valid = column(statement, 0, sale.vendorHash, 1) && column(statement, 1, sale.saleIndex, 0) + && column(statement, 2, sale.factionHash, 1) + && column(statement, 3, sale.placeholderHash, 1) + && column(statement, 4, sale.costHash, 1) + && column(statement, 5, sale.categoryIndex, 0) + && column(statement, 6, sale.costQuantity, 1) + && column(statement, 7, sale.experiencePerUnit, 1); + if (!valid + || (count != 0 + && (sales[count - 1].vendorHash > sale.vendorHash + || (sales[count - 1].vendorHash == sale.vendorHash + && sales[count - 1].saleIndex >= sale.saleIndex)))) { + valid = false; + break; + } + sales[count++] = sale; + } + const bool complete = valid && status == SQLITE_DONE && count != 0; + const int finalized = sqlite3_finalize(statement); + return complete && finalized == SQLITE_OK; +} + +} // namespace + +/** + * Publish only complete content sales, independent of player-save SQLite. + * @param module DLL holding the content resource. + * @return False when SQL or any sale row is invalid. + */ +bool load_reputation_sales(void* module) noexcept { + clear_reputation_sales(); + std::string sql; + if (!resource_sql(module, sql)) { + return false; + } + sqlite3* database = nullptr; + if (sqlite3_open(":memory:", &database) != SQLITE_OK) { + sqlite3_close(database); + return false; + } + std::array sales{}; + std::size_t count = 0; + const bool complete = + sqlite3_exec(database, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK + && read_sales(database, sales, count); + const int closed = sqlite3_close(database); + if (!complete || closed != SQLITE_OK) { + return false; + } + const std::lock_guard guard(g_lock); + g_sales = sales; + g_saleCount = count; + return true; +} + +/** Clears the small content catalog under its reader lock. */ +void clear_reputation_sales() noexcept { + const std::lock_guard guard(g_lock); + g_sales = {}; + g_saleCount = 0; +} + +/** + * Copy one exact vendor sale under the catalog lock. + * @param vendorHash Installed vendor hash. + * @param saleIndex Sale selector within that vendor. + * @param sale Receives the row, or an empty row when absent. + * @return True when the row exists. + */ +bool find_reputation_sale(std::uint32_t vendorHash, + std::uint16_t saleIndex, + ReputationSale& sale) noexcept { + sale = {}; + const std::shared_lock guard(g_lock); + for (std::size_t index = 0; index < g_saleCount; ++index) { + if (g_sales[index].vendorHash == vendorHash && g_sales[index].saleIndex == saleIndex) { + sale = g_sales[index]; + return true; + } + } + return false; +} + +/** + * Keep a known turn-in placeholder out of ordinary item acquisition. + * @param vendorHash Installed vendor hash. + * @param placeholderHash Requested item hash. + * @return True when this vendor uses the item as a turn-in placeholder. + */ +bool is_reputation_placeholder(std::uint32_t vendorHash, std::uint32_t placeholderHash) noexcept { + const std::shared_lock guard(g_lock); + for (std::size_t index = 0; index < g_saleCount; ++index) { + if (g_sales[index].vendorHash == vendorHash + && g_sales[index].placeholderHash == placeholderHash) { + return true; + } + } + return false; +} + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.h b/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.h new file mode 100644 index 000000000..a8879bde5 --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace sunrise::state::build_data::vendors { + +/** Bounds the build-era manifest's 95 candidate turn-ins; only verified rows are loaded. */ +inline constexpr std::size_t kReputationSaleCapacity = 128; + +/** One authored turn-in sale; the installed vendor row must match before it awards XP. */ +struct ReputationSale { + std::uint32_t vendorHash{}; + std::uint16_t saleIndex{}; + std::uint32_t factionHash{}; + std::uint32_t placeholderHash{}; + std::uint32_t costHash{}; + std::int32_t categoryIndex{}; + std::uint32_t costQuantity{}; + std::int32_t experiencePerUnit{}; +}; + +/** Loads the build-scoped authored sales embedded in the DLL; no player save is opened. */ +[[nodiscard]] bool load_reputation_sales(void* module) noexcept; + +/** Withdraws every authored sale before build-data shutdown or failed publication. */ +void clear_reputation_sales() noexcept; + +/** Finds the authored sale for a vendor selector pair. */ +[[nodiscard]] bool find_reputation_sale(std::uint32_t vendorHash, + std::uint16_t saleIndex, + ReputationSale& sale) noexcept; + +/** Prevents a known reputation placeholder falling through to ordinary item acquisition. */ +[[nodiscard]] bool is_reputation_placeholder(std::uint32_t vendorHash, + std::uint32_t placeholderHash) noexcept; + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/build_data/vendors/vendor_catalog.cpp b/Sunrise/src/state/build_data/vendors/vendor_catalog.cpp index 3f6fa63a8..73ffa61d2 100644 --- a/Sunrise/src/state/build_data/vendors/vendor_catalog.cpp +++ b/Sunrise/src/state/build_data/vendors/vendor_catalog.cpp @@ -5,6 +5,7 @@ #include "../table.h" #include "core/threading/srw_lock.h" +#include "vendor_gate_catalog.h" namespace sunrise::state::build_data::vendors { namespace { @@ -137,6 +138,7 @@ template /** Clears the index, every held definition, and both row banks under the catalog lock. */ void clear() noexcept { + clear_gates(); const std::lock_guard guard(g_lock); g_index.clear(); g_definitions.clear(); @@ -184,6 +186,8 @@ bool replace(std::span index, if (!valid(index, definitions, saleRows, installedRows)) { return false; } + // Installed gate expressions must be rebuilt for the replacement rows. + clear_gates(); const std::lock_guard guard(g_lock); // All four run with no short-circuit, so the set cannot be left half replaced. Capacity is // the only reason one can refuse, and valid() already checked it. diff --git a/Sunrise/src/state/build_data/vendors/vendor_expression.cpp b/Sunrise/src/state/build_data/vendors/vendor_expression.cpp deleted file mode 100644 index 6e3464e41..000000000 --- a/Sunrise/src/state/build_data/vendors/vendor_expression.cpp +++ /dev/null @@ -1,119 +0,0 @@ -#include "vendor_expression.h" - -#include - -namespace sunrise::state::build_data::vendors { -namespace { - -/** What one stack slot holds. The two kinds never substitute for each other. */ -enum class Kind : std::uint8_t { - boolean, - number, -}; - -/** One typed stack slot. */ -struct Slot { - Kind kind{}; - bool boolean{}; - std::int32_t number{}; -}; - -/** Fixed evaluation stack, which refuses rather than growing. */ -class Stack final { -public: - /** @param slot Value to push. @return True when there was room. */ - [[nodiscard]] bool push(const Slot& slot) noexcept { - if (depth_ == slots_.size()) { - return false; - } - slots_[depth_++] = slot; - return true; - } - - /** @param kind Required kind. @param slot Receives the value. @return True when it matched. */ - [[nodiscard]] bool pop(Kind kind, Slot& slot) noexcept { - if (depth_ == 0 || slots_[depth_ - 1].kind != kind) { - return false; - } - slot = slots_[--depth_]; - return true; - } - - /** @return Slots currently held. */ - [[nodiscard]] std::size_t depth() const noexcept { - return depth_; - } - -private: - std::array slots_{}; - std::size_t depth_{}; -}; - -/** @param value Boolean result. @return The stack slot holding it. */ -[[nodiscard]] Slot boolean_slot(bool value) noexcept { - return {Kind::boolean, value, 0}; -} - -/** @param value Numeric result. @return The stack slot holding it. */ -[[nodiscard]] Slot number_slot(std::int32_t value) noexcept { - return {Kind::number, false, value}; -} - -/** - * Runs one instruction against the stack. - * @param instruction Instruction to run. - * @param inputs Flag and value readers. - * @param stack Evaluation stack. - * @return True when the opcode is known and its operands were there in the right kind. - */ -[[nodiscard]] bool -step(const Instruction& instruction, const Inputs& inputs, Stack& stack) noexcept { - Slot left{}; - Slot right{}; - switch (instruction.opcode) { - case Opcode::flag: { - std::uint8_t logical = 0; - return inputs.flag(inputs.context, instruction.operand, logical) - && stack.push(boolean_slot(logical == kFlagActive)); - } - case Opcode::logicalNot: - return stack.pop(Kind::boolean, left) && stack.push(boolean_slot(!left.boolean)); - case Opcode::loadValue: { - std::int32_t value = 0; - return inputs.value(inputs.context, instruction.operand, value) - && stack.push(number_slot(value)); - } - case Opcode::constant: - return stack.push(number_slot(instruction.operand)); - case Opcode::lessThan: - // The right operand was pushed last, so it comes off first. - return stack.pop(Kind::number, right) && stack.pop(Kind::number, left) - && stack.push(boolean_slot(left.number < right.number)); - default: - return false; - } -} - -} // namespace - -/** Evaluates one expression program. */ -bool evaluate(std::span program, const Inputs& inputs, bool& result) noexcept { - result = false; - if (program.empty() || inputs.flag == nullptr || inputs.value == nullptr) { - return false; - } - Stack stack; - for (const Instruction& instruction : program) { - if (!step(instruction, inputs, stack)) { - return false; - } - } - Slot final{}; - if (stack.depth() != 1 || !stack.pop(Kind::boolean, final)) { - return false; - } - result = final.boolean; - return true; -} - -} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/build_data/vendors/vendor_expression.h b/Sunrise/src/state/build_data/vendors/vendor_expression.h deleted file mode 100644 index 3bc56eb29..000000000 --- a/Sunrise/src/state/build_data/vendors/vendor_expression.h +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace sunrise::state::build_data::vendors { - -/** Stack slots one expression may use. Known gates need two, so this leaves headroom. */ -inline constexpr std::size_t kExpressionStackCapacity = 16; - -/** Opcodes the client's expression evaluator implements. */ -enum class Opcode : std::uint8_t { - /** Pushes true when the flag's logical byte is `kFlagActive`. Operand is the flag slot. */ - flag = 1, - /** Pops one boolean and pushes its negation. */ - logicalNot = 2, - /** Pushes one progression value. Operand is the value slot. */ - loadValue = 10, - /** Pushes the operand as a constant. */ - constant = 11, - /** Pops right then left and pushes left less than right. */ - lessThan = 15, -}; - -/** A flag is active only for this logical byte. Logical 0 and 1 are both inactive. */ -inline constexpr std::uint8_t kFlagActive = 2; - -/** One expression instruction. Opcodes with no operand leave it unread. */ -struct Instruction { - Opcode opcode{}; - std::uint16_t operand{}; -}; - -/** Reads one flag's logical byte. Returns false when the slot has no value. */ -using FlagReader = bool (*)(void* context, std::uint16_t slot, std::uint8_t& logical) noexcept; -/** Reads one progression value. Returns false when the slot has no value. */ -using ValueReader = bool (*)(void* context, std::uint16_t slot, std::int32_t& value) noexcept; - -/** The two readers one evaluation needs, and the caller state they share. */ -struct Inputs { - FlagReader flag{}; - ValueReader value{}; - void* context{}; -}; - -/** - * Evaluates one expression program. - * Every failure is refused, never defaulted: bad opcode, bad stack, bad kind, unreadable slot. - * @param program Instructions in evaluation order. - * @param inputs Flag and value readers. - * @param result Receives the expression result, and is cleared on any refusal. - * @return True only when the whole program evaluated to one boolean. - */ -[[nodiscard]] bool -evaluate(std::span program, const Inputs& inputs, bool& result) noexcept; - -} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.cpp b/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.cpp new file mode 100644 index 000000000..d294ee489 --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.cpp @@ -0,0 +1,123 @@ +#include "vendor_gate_catalog.h" + +#include +#include +#include + +#include "core/threading/srw_lock.h" + +namespace sunrise::state::build_data::vendors { +namespace { + +core::threading::SrwLock g_lock; +std::vector g_interactions; +std::vector g_sales; +bool g_ready{}; + +/** A reader must never span past one published gate's fixed storage. */ +[[nodiscard]] bool valid_gate(const Gate& gate) noexcept { + if (gate.program.count > gate.program.instructions.size() + || gate.inputCount > gate.inputs.size() || gate.inputCount > gate.program.count) { + return false; + } + const auto program = std::span{gate.program.instructions}.first(gate.program.count); + return std::all_of(program.begin(), program.end(), unlocks::valid); +} + +/** @tparam Row Vendor-keyed gate row. @param rows Candidate bank. @return False for a duplicate. */ +template [[nodiscard]] bool unique_rows(std::span rows) noexcept { + for (std::size_t first = 0; first < rows.size(); ++first) { + for (std::size_t other = first + 1; other < rows.size(); ++other) { + if (rows[first].vendorHash == rows[other].vendorHash + && rows[first].index == rows[other].index) { + return false; + } + } + } + return true; +} + +/** + * @tparam Row Vendor-keyed gate row. + * @param rows Published bank. + * @param vendorHash Vendor definition identity. + * @param index Native row index. + * @param output Receives the row, cleared when absent. + * @return True when one exact row was retained. + */ +template +[[nodiscard]] bool find_row(std::span rows, + std::uint32_t vendorHash, + std::uint16_t index, + Row& output) noexcept { + output = {}; + const auto found = std::find_if(rows.begin(), rows.end(), [&](const Row& row) { + return row.vendorHash == vendorHash && row.index == index; + }); + if (found == rows.end()) { + return false; + } + output = *found; + return true; +} + +} // namespace + +/** Replaces the checked process-local gate set under one lock. */ +bool replace_gates(std::span interactions, + std::span sales) noexcept { + if (!unique_rows(interactions) || !unique_rows(sales)) { + return false; + } + if (std::any_of(interactions.begin(), + interactions.end(), + [](const auto& row) { + return !valid_gate(row.condition) + || !std::all_of(row.replyConditions.begin(), + row.replyConditions.end(), + valid_gate); + }) + || std::any_of(sales.begin(), sales.end(), [](const auto& row) { + return !valid_gate(row.admission) || !valid_gate(row.selection); + })) { + return false; + } + std::vector nextInteractions(interactions.begin(), interactions.end()); + std::vector nextSales(sales.begin(), sales.end()); + const std::lock_guard guard(g_lock); + g_interactions.swap(nextInteractions); + g_sales.swap(nextSales); + g_ready = true; + return true; +} + +/** Invalidates only derived gate expressions, leaving cached vendor rows intact. */ +void clear_gates() noexcept { + const std::lock_guard guard(g_lock); + g_interactions.clear(); + g_sales.clear(); + g_ready = false; +} + +/** @return True when one complete gate extraction was published this process. */ +bool gates_ready() noexcept { + const std::shared_lock guard(g_lock); + return g_ready; +} + +/** Copies one interaction gate without exposing the mutable published bank. */ +bool find_interaction_gate(std::uint32_t vendorHash, + std::uint16_t index, + InteractionGate& output) noexcept { + const std::shared_lock guard(g_lock); + return g_ready + && find_row(std::span{g_interactions}, vendorHash, index, output); +} + +/** Copies one sale's gates without exposing the mutable published bank. */ +bool find_sale_gates(std::uint32_t vendorHash, std::uint16_t index, SaleGates& output) noexcept { + const std::shared_lock guard(g_lock); + return g_ready && find_row(std::span{g_sales}, vendorHash, index, output); +} + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.h b/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.h new file mode 100644 index 000000000..1c626a0dd --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../../unlocks/unlocks_expression.h" + +namespace sunrise::state::build_data::vendors { + +/** The fixed gate bank retains at most 128 instructions from one vendor expression. */ +inline constexpr std::size_t kVendorProgramCapacity = 128; + +using Opcode = unlocks::Opcode; + +/** One bound native postfix program; zero instructions mean no authored gate. */ +struct Program { + std::array instructions{}; + std::size_t count{}; +}; + +/** A Family-5 override takes precedence; otherwise a gate reads its mapped saved bank. */ +enum class GateBank : std::uint8_t { + external, + accountFlag, + profileFlag, + characterFlag, + accountValue, + characterValue, +}; + +/** One native slot resolved against the installed build's unlock mapping tables. */ +struct GateInput { + Opcode opcode{}; + std::uint16_t slot{}; + GateBank bank{}; + std::uint16_t row{}; +}; + +/** One interaction or sale expression with its bounded input bindings. */ +struct Gate { + Program program{}; + std::array inputs{}; + std::size_t inputCount{}; +}; + +/** Native interaction row belonging to a vendor category. */ +struct InteractionGate { + std::uint32_t vendorHash{}; + std::uint16_t index{}; + std::int32_t categoryIndex{}; + Gate condition{}; + /** Conditional reward programs in native reply order; they do not select a sale. */ + std::vector replyConditions; +}; + +/** Native sale condition fields for one installed package offer. */ +struct SaleGates { + std::uint32_t vendorHash{}; + std::uint16_t index{}; + std::int32_t categoryIndex{}; + Gate admission{}; + Gate selection{}; +}; + +/** + * Publishes process-local interaction, reply and sale gates; cached vendor rows omit them. + * @param interactions Native interaction gates, unique by vendor and row index. + * @param sales Native sale gates, unique by vendor and row index. + * @return False when ranges or identities are invalid; no partial publication occurs. + */ +[[nodiscard]] bool replace_gates(std::span interactions, + std::span sales) noexcept; +/** Withdraws expressions before vendor rows are replaced or cleared. */ +void clear_gates() noexcept; +/** @return True when one complete extraction was published this process. */ +[[nodiscard]] bool gates_ready() noexcept; +/** @return True when the exact vendor and interaction row was retained. */ +[[nodiscard]] bool find_interaction_gate(std::uint32_t vendorHash, + std::uint16_t index, + InteractionGate& output) noexcept; +/** @return True when the exact vendor and sale row was retained. */ +[[nodiscard]] bool +find_sale_gates(std::uint32_t vendorHash, std::uint16_t index, SaleGates& output) noexcept; + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/investment/investment_account_store.cpp b/Sunrise/src/state/investment/investment_account_store.cpp index 31083d8d3..c6b63bef2 100644 --- a/Sunrise/src/state/investment/investment_account_store.cpp +++ b/Sunrise/src/state/investment/investment_account_store.cpp @@ -142,8 +142,10 @@ bool write_account(const AccountState& value) noexcept { } Statement accountRow("INSERT OR REPLACE INTO account VALUES (1,?,?)"); if (!accountRow.write(value.primarySoid, value.profileSetupCompleted) - || !write_characters(value) || !write_inventory(value) || !write_settings(value.settings) - || !transaction.commit()) { + || !write_characters(value) + || !execute("DELETE FROM character_objective_values " + "WHERE character_soid NOT IN (SELECT soid FROM characters)") + || !write_inventory(value) || !write_settings(value.settings) || !transaction.commit()) { return false; } for (std::size_t index = 0; index < value.characters.size(); ++index) { diff --git a/Sunrise/src/state/investment/investment_database.cpp b/Sunrise/src/state/investment/investment_database.cpp index e92c16671..364cf56eb 100644 --- a/Sunrise/src/state/investment/investment_database.cpp +++ b/Sunrise/src/state/investment/investment_database.cpp @@ -119,12 +119,12 @@ bool open(std::string_view path, && execute(preferenceSchema.c_str()) && execute(preferenceDefaults.c_str()) && transaction.commit(); } else if (ready) { - // Version 2 adds account preferences and per-item seen state. - constexpr int kSchemaVersion = 2; + // Version 3 adds character-owned objective counters. + constexpr int kSchemaVersion = 3; constexpr int kApplicationId = 1397902921; int application = 0; Statement query("PRAGMA application_id"); - ready = (version == 1 || version == kSchemaVersion) && query.step() == SQLITE_ROW + ready = (version >= 1 && version <= kSchemaVersion) && query.step() == SQLITE_ROW && query.column(0, application) && application == kApplicationId; } if (ready && version == 1) { @@ -137,6 +137,21 @@ bool open(std::string_view path, "IN(0,1));") && execute(preferenceSchema.c_str()) && execute(preferenceDefaults.c_str()) && execute("PRAGMA user_version=2") && transaction.commit(); + if (ready) { + version = 2; + } + } + if (ready && version == 2) { + Transaction transaction; + // Account writes rebuild character rows before the outer transaction ends. + ready = transaction.ready() + && execute("CREATE TABLE character_objective_values (" + "character_soid INTEGER NOT NULL REFERENCES characters(soid) " + "DEFERRABLE INITIALLY DEFERRED," + "slot INTEGER NOT NULL CHECK (slot BETWEEN 0 AND 32767)," + "value INTEGER NOT NULL CHECK (value BETWEEN 0 AND 2147483647)," + "PRIMARY KEY (character_soid,slot)) STRICT;") + && execute("PRAGMA user_version=3") && transaction.commit(); } if (!ready) { shutdown(); diff --git a/Sunrise/src/state/investment/store_internal.h b/Sunrise/src/state/investment/store_internal.h index 49178575a..2b6dab6b3 100644 --- a/Sunrise/src/state/investment/store_internal.h +++ b/Sunrise/src/state/investment/store_internal.h @@ -21,6 +21,8 @@ struct Session { std::array selected{}; std::array activities{}; std::uint64_t signInSeconds{}; + /** Set while an earned-perk walk is owed; a rolled-back transaction restores it. */ + bool perkWalkOwed{true}; }; extern Session g_session; diff --git a/Sunrise/src/state/progression/season_pass_reward_catalog.h b/Sunrise/src/state/progression/season_pass_reward_catalog.h index cb32e3d33..2ce03e78b 100644 --- a/Sunrise/src/state/progression/season_pass_reward_catalog.h +++ b/Sunrise/src/state/progression/season_pass_reward_catalog.h @@ -1,9 +1,9 @@ #pragma once -#include -#include #include +#include "../build_data/season_pass/definition.h" + namespace sunrise::state::progression::season_pass { /** Account progression used by the installed Season of Arrivals pass. */ @@ -11,187 +11,8 @@ inline constexpr std::uint16_t kProgressionDefinitionIndex = 40; /** Repeating HUD bar paired with the Arrivals progression. */ inline constexpr std::uint16_t kHudProgressionDefinitionIndex = 41; -/** - * The pass package expands into every installed Season 11 planetary-material stack. - * TODO: extract; the package names a sack reward set, not an item list, and no reader - * resolves a reward set to its members yet. - */ -inline constexpr std::uint32_t kDestinationResourceBundleHash = 3104539653U; -inline constexpr std::uint16_t kDestinationResourceQuantity = 50; -inline constexpr std::array kDestinationResourceHashes{ - 3592324052U, // Helium Filaments - 31293053U, // Seraphite - 49145143U, // Simulation Seed - 2014411539U, // Alkane Dust - 1305274547U, // Phaseglass Needle - 950899352U, // Dusklight Shard - 3487922223U, // Microphasic Datalattice - 592227263U, // Baryon Bough - 1177810185U, // Etheric Spiral -}; - -/** The two auto-decrypting engram item definitions. */ -inline constexpr std::uint32_t kLegendaryEngramHash = 2223145359U; -inline constexpr std::uint32_t kExoticEngramHash = 3875551374U; - -/** - * Exact non-class rewards advertised by the two auto-decrypting engram definitions. - * TODO: extract; an engram names a sack reward set rather than items, and no reader resolves - * a reward set to its members yet. - */ -inline constexpr std::array kLegendaryEngramWeapons{ - 991314988U, 720351795U, 4230993599U, 253196586U, 4146702548U, 2957367743U, - 2009277538U, 3745990145U, 3356526253U, 821154603U, 3504336176U, 2199171672U, - 188882152U, 3863882743U, 4106983932U, 3569802112U, 1529450902U, 1807343361U, - 3622137132U, 3055192515U, 2257180473U, 2742838701U, 2742838700U, 1162247618U, - 1723380073U, 2807687156U, 1786797708U, 2857348871U, 1946491241U, 1835747805U, -}; -/** Legendary engram Titan armour rewards. */ -inline constexpr std::array kLegendaryTitanArmour{ - 3852389988U, - 3250360146U, - 362404956U, - 3299386902U, - 1648238545U, - 2629014079U, - 881579413U, - 2112821379U, - 1063507982U, - 3651598572U, - 1076538456U, - 1560040304U, - 445618861U, - 238618945U, - 3406670226U, - 3238424670U, - 1566911695U, - 1407026808U, -}; -/** Legendary engram Hunter armour rewards. */ -inline constexpr std::array kLegendaryHunterArmour{ - 3239215026U, - 974507844U, - 1399263478U, - 2808379196U, - 1153347999U, - 265279665U, - 2567710435U, - 2298664693U, - 4064910796U, - 2905153902U, - 3750877150U, - 344824594U, - 940065571U, - 382498903U, - 299852984U, - 3554672786U, - 902989307U, - 2944336620U, -}; -/** Legendary engram Warlock armour rewards. */ -inline constexpr std::array kLegendaryWarlockArmour{ - 1920259123U, - 4239920089U, - 695071581U, - 2364041279U, - 2581516944U, - 3611199822U, - 2339155434U, - 597618504U, - 1655109893U, - 2837138379U, - 785967407U, - 3931361417U, - 1416697412U, - 1557571326U, - 936010065U, - 2640935765U, - 4245441464U, - 3523809305U, -}; - -/** Exotic engram non-class rewards. */ -inline constexpr std::array kExoticEngramWeapons{ - 4068264807U, - 2130065553U, - 3325463374U, - 1541131350U, - 814876685U, - 2694576561U, - 3766045777U, - 1852863732U, - 2044500762U, - 3413860063U, -}; -/** Exotic engram Titan armour rewards. */ -inline constexpr std::array kExoticTitanArmour{ - 1190497097U, - 2326396534U, - 2255796155U, - 2240152949U, - 2578771006U, - 2423243921U, - 1734844650U, - 2808156426U, - 106575079U, - 1591207518U, -}; -/** Exotic engram Hunter armour rewards. */ -inline constexpr std::array kExoticHunterArmour{ - 2268523867U, - 1219761634U, - 2757274117U, - 4165919945U, - 1474735276U, - 978537162U, - 1688602431U, - 1163283805U, - 896224899U, - 903984858U, -}; -/** Exotic engram Warlock armour rewards. */ -inline constexpr std::array kExoticWarlockArmour{ - 2822465023U, - 235591051U, - 3948284065U, - 3084282676U, - 4057299719U, - 121305948U, - 3288917178U, - 3627185503U, - 1725917554U, - 1030017949U, -}; - -template -/** @return True when the reward array holds that item hash. */ -[[nodiscard]] constexpr bool contains(const std::array& hashes, - std::uint32_t hash) noexcept { - for (const std::uint32_t candidate : hashes) { - if (candidate == hash) { - return true; - } - } - return false; -} - -/** Checks one decrypted result against its wrapper's weapon and active-class pools. */ -[[nodiscard]] constexpr bool contains_engram_reward(std::uint32_t engramHash, - std::uint32_t rewardHash, - std::uint8_t characterClass) noexcept { - if (engramHash == kLegendaryEngramHash) { - return contains(kLegendaryEngramWeapons, rewardHash) - || (characterClass == 0 && contains(kLegendaryTitanArmour, rewardHash)) - || (characterClass == 1 && contains(kLegendaryHunterArmour, rewardHash)) - || (characterClass == 2 && contains(kLegendaryWarlockArmour, rewardHash)); - } - if (engramHash == kExoticEngramHash) { - return contains(kExoticEngramWeapons, rewardHash) - || (characterClass == 0 && contains(kExoticTitanArmour, rewardHash)) - || (characterClass == 1 && contains(kExoticHunterArmour, rewardHash)) - || (characterClass == 2 && contains(kExoticWarlockArmour, rewardHash)); - } - return false; -} +/** Acquisition flag of a direct perk reward, or build_data::rewards::kAbsent for an item or + * package. */ +[[nodiscard]] std::uint16_t progress_flag(const build_data::season_pass::Reward& reward) noexcept; } // namespace sunrise::state::progression::season_pass diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp new file mode 100644 index 000000000..757296e0b --- /dev/null +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -0,0 +1,487 @@ +#include "reward_resolver.h" + +#include +#include +#include + +#include "../build_data/runtime.h" +#include "../unlocks/unlocks_expression.h" + +namespace sunrise::state::reward_resolution { +namespace { + +namespace definitions = build_data::rewards; +using AcquiredFlags = std::bitset; + +/** Completed acquisitions are visible to later draws without changing saved unlocks. */ +struct ConditionInputs { + const Context& context; + const AcquiredFlags* acquiredFlags{}; +}; + +/** The empty category tag matches every wrapper selection. */ +constexpr std::uint32_t kEmptyTag = 0x811C9DC5U; +/** Native wrappers retain up to 64 selections, including rows without an item grant. */ +constexpr std::size_t kDrawCapacity = 64; +/** A grant quantity is published as a signed 32-bit stack size. */ +constexpr auto kMaximumQuantity = + static_cast((std::numeric_limits::max)()); + +bool refuse(const Context& context, const char* reason) noexcept { + if (context.refusal != nullptr) { + *context.refusal = reason; + } + return false; +} + +/** Reads one validated flag from the caller's unlock banks or selected character. */ +bool read_flag(const void* raw, const unlocks::Instruction& instruction, bool& set) noexcept { + const auto& inputs = *static_cast(raw); + const auto& context = inputs.context; + const auto index = instruction.operand; + switch (instruction.bank) { + case unlocks::Bank::account: + set = (inputs.acquiredFlags != nullptr && (*inputs.acquiredFlags)[index]) + || context.unlocks.accountFlags[index] == unlocks::kFlagSet; + return true; + case unlocks::Bank::profile: + set = context.unlocks.profileFlags[index] == unlocks::kFlagSet; + return true; + case unlocks::Bank::character: + set = context.unlocks.characterObjectFlags[index] == unlocks::kFlagSet; + return true; + case unlocks::Bank::characterClass: + set = index == static_cast(context.characterClass); + return true; + default: + return false; + } +} + +/** Reads one validated value from the caller's unlock banks. */ +bool read_value(const void* raw, + const unlocks::Instruction& instruction, + std::int32_t& value) noexcept { + const auto& context = static_cast(raw)->context; + switch (instruction.bank) { + case unlocks::Bank::account: + value = context.unlocks.objectiveValues[instruction.operand]; + return true; + case unlocks::Bank::character: + value = context.unlocks.characterObjectValues[instruction.operand]; + return true; + default: + return false; + } +} + +/** An empty program is unconditional; external identities are not in the saved banks. */ +bool condition(std::span program, + const Context& context, + bool& result, + const AcquiredFlags* acquiredFlags = nullptr) noexcept { + result = program.empty(); + if (program.empty()) { + return true; + } + for (const auto& instruction : program) { + if (!unlocks::valid(instruction)) { + return refuse(context, "condition_shape"); + } + if (instruction.bank == unlocks::Bank::external) { + return refuse(context, + instruction.opcode == unlocks::Opcode::flag ? "external_flag" + : "external_value"); + } + } + const ConditionInputs conditionInputs{context, acquiredFlags}; + const unlocks::Inputs inputs{read_flag, read_value, &conditionInputs}; + return unlocks::evaluate(program, inputs, result) || refuse(context, "condition_shape"); +} + +bool condition(definitions::View data, + definitions::Range expression, + const Context& context, + bool& result, + const AcquiredFlags& acquiredFlags) noexcept { + if (!definitions::fits(expression, data.instructions)) { + result = false; + return refuse(context, "condition_shape"); + } + return condition(data.instructions.subspan(expression.first, expression.count), + context, + result, + &acquiredFlags); +} + +/** SplitMix64 supplies replayable wrapper seeds and wrapper-local draws. */ +std::uint64_t next_random(std::uint64_t& state) noexcept { + state += 0x9E3779B97F4A7C15ULL; + auto bits = state; + bits = (bits ^ (bits >> 30)) * 0xBF58476D1CE4E5B9ULL; + bits = (bits ^ (bits >> 27)) * 0x94D049BB133111EBULL; + return bits ^ (bits >> 31); +} + +struct Resolver { + definitions::View data; + const Context& context; + Result& result; + std::uint64_t random; + std::uint64_t& wrapperSeed; + AcquiredFlags& acquiredFlags; + bool& excludedByUnlocks; + bool& leafDrawn; + bool& leafFlagAcquired; + Resolution& failure; + std::size_t wrapperDepth{}; + std::array selectedEntries{}; + std::array selectedCategories{}; + std::size_t selectedCount{}; + + double fraction() noexcept { + return static_cast(next_random(random) >> 11) * 0x1.0p-53; + } + + bool weight(const definitions::Entry& entry, + std::uint32_t category, + std::size_t depth, + double& output) noexcept { + output = 0; + if (entry.categoryHash != kEmptyTag && entry.categoryHash != category) { + return true; + } + // Draws exclude the selected pool row within its category, not every copy of its item. + for (std::size_t i = 0; i < selectedCount; ++i) { + if (selectedEntries[i] == &entry && selectedCategories[i] == category) { + return true; + } + } + bool enabled = false; + if (!condition(data, entry.condition, context, enabled, acquiredFlags)) { + return false; + } + if (!enabled) { + excludedByUnlocks = true; + return true; + } + double value = entry.weight; + if (!definitions::fits(entry.modifiers, data.modifiers)) { + return refuse(context, "modifier_range"); + } + for (const auto& modifier : + data.modifiers.subspan(entry.modifiers.first, entry.modifiers.count)) { + if (!condition(data, modifier.condition, context, enabled, acquiredFlags)) { + return false; + } + if (enabled) { + if (modifier.valueIndex != definitions::kAbsent) { + return refuse(context, "indexed_weight"); + } + // The first matching modifier replaces the weight; a negative value keeps it. + if (modifier.value >= 0) { + value = modifier.value; + } + break; + } + } + if (!std::isfinite(value) || value < 0) { + return refuse(context, "weight_shape"); + } + if (value == 0) { + // A modifier can change this weight when the unlocks change. + excludedByUnlocks |= entry.modifiers.count != 0; + return true; + } + if (entry.poolIndex != definitions::kAbsent) { + double total = 0; + if (!pool_weight(entry.poolIndex, category, depth + 1, total)) { + return false; + } + if (total == 0) { + return true; + } + } else if (entry.itemIndex != definitions::kAbsent) { + if (entry.itemIndex >= data.items.size()) { + return refuse(context, "item_range"); + } + } else if (entry.supplementalIndex == definitions::kAbsent) { + return refuse(context, "reward_target"); + } + output = value; + return true; + } + + bool pool_weight(std::uint16_t index, + std::uint32_t category, + std::size_t depth, + double& total) noexcept { + total = 0; + if (depth >= definitions::kTraversalDepth) { + return refuse(context, "pool_depth"); + } + if (index >= data.pools.size()) { + return refuse(context, "pool_range"); + } + const auto range = data.pools[index].entries; + if (!definitions::fits(range, data.entries)) { + return refuse(context, "entry_range"); + } + for (const auto& entry : data.entries.subspan(range.first, range.count)) { + double value = 0; + if (!weight(entry, category, depth, value)) { + return false; + } + total += value; + } + return std::isfinite(total) || refuse(context, "weight_shape"); + } + + bool + draw(std::uint16_t index, std::uint32_t category, std::size_t depth, double total) noexcept { + const auto range = data.pools[index].entries; + double remaining = fraction() * total; + const definitions::Entry* chosen = nullptr; + for (const auto& entry : data.entries.subspan(range.first, range.count)) { + double value = 0; + if (!weight(entry, category, depth, value)) { + return false; + } + if (value == 0) { + continue; + } + chosen = &entry; + remaining -= value; + if (remaining < 0) { + break; + } + } + if (chosen == nullptr) { + return refuse(context, "empty_pool"); + } + if (chosen->supplementalIndex != definitions::kAbsent && !chosen->supplementalMissing) { + return refuse(context, "supplemental_reward"); + } + // A missing supplemental definition does not cancel the selected item grant. + if (chosen->poolIndex != definitions::kAbsent) { + // A pool reference selects one leaf; quantity belongs to that leaf. + double childTotal = 0; + if (!pool_weight(chosen->poolIndex, category, depth + 1, childTotal)) { + return false; + } + if (childTotal == 0) { + return refuse(context, "empty_pool"); + } + return draw(chosen->poolIndex, category, depth + 1, childTotal); + } + if (selectedCount == selectedEntries.size()) { + failure = Resolution::exceeded; + return refuse(context, "reward_selection_capacity"); + } + selectedEntries[selectedCount] = chosen; + selectedCategories[selectedCount++] = category; + if (chosen->itemIndex == definitions::kAbsent) { + leafDrawn = true; + return true; + } + return acquire_item(*chosen); + } + + /** Completes one acquisition after its children have finished. */ + bool acquire_flag(const definitions::Item& item, bool wrapper) noexcept { + if (item.acquiredFlag == definitions::kAbsent) { + return true; + } + if (item.acquiredFlag >= acquiredFlags.size()) { + return refuse(context, "acquisition_flag"); + } + acquiredFlags.set(item.acquiredFlag); + if (wrapper) { + result.wrapperFlags.set(item.acquiredFlag); + } + return true; + } + + /** Acquires a selected item, opening automatic wrappers before the next parent draw. */ + bool acquire_item(const definitions::Entry& chosen) noexcept { + if (chosen.quantity == 0 || chosen.quantity > kMaximumQuantity) { + return refuse(context, "reward_quantity"); + } + if (!definitions::fits(chosen.sockets, data.sockets)) { + return refuse(context, "socket_range"); + } + const auto& item = data.items[chosen.itemIndex]; + if (item.definitionHash == 0) { + return refuse(context, "item_unavailable"); + } + if (item.poolIndex != definitions::kAbsent + && (item.flags & definitions::kOpenOnAcquisition) != 0) { + if (chosen.quantity != 1) { + return refuse(context, "wrapper_quantity"); + } + // These sockets belong to the consumed instance, not its descendant items. + if (chosen.sockets.count != 0) { + return refuse(context, "wrapper_socket_override"); + } + bool childLeafDrawn = false; + Resolver child{data, + context, + result, + next_random(wrapperSeed), + wrapperSeed, + acquiredFlags, + excludedByUnlocks, + childLeafDrawn, + leafFlagAcquired, + failure, + wrapperDepth + 1}; + if (!child.open_wrapper(item)) { + return false; + } + // A child's flag is set after opening whatever it drew, but only a child that drew + // a leaf row makes the resolution non-empty. + if (childLeafDrawn) { + leafDrawn = true; + leafFlagAcquired |= item.acquiredFlag != definitions::kAbsent; + } + return acquire_flag(item, true); + } + if (result.count == result.grants.size()) { + failure = Resolution::exceeded; + return refuse(context, "grant_capacity"); + } + leafDrawn = true; + auto& grant = result.grants[result.count++]; + grant.itemIndex = chosen.itemIndex; + grant.quantity = static_cast(chosen.quantity); + if (chosen.sockets.count > grant.sockets.size()) { + return refuse(context, "socket_capacity"); + } + grant.socketCount = chosen.sockets.count; + std::copy_n( + data.sockets.begin() + chosen.sockets.first, grant.socketCount, grant.sockets.begin()); + return acquire_flag(item, false); + } + + /** Each wrapper owns its selection history and random stream; pool references share them. */ + bool open_wrapper(const definitions::Item& item) noexcept { + if (wrapperDepth >= definitions::kTraversalDepth) { + return refuse(context, "wrapper_depth"); + } + if (item.selectionCount == 0 || item.selectionCount > item.selections.size()) { + return refuse(context, "wrapper_selections"); + } + for (std::size_t i = 0; i < item.selectionCount; ++i) { + const auto& declared = item.selections[i]; + for (std::size_t j = 0; j < declared.count; ++j) { + double total = 0; + if (!pool_weight(item.poolIndex, declared.categoryHash, 0, total)) { + return false; + } + // Completed acquisitions can leave fewer eligible members than requested. + if (total == 0) { + break; + } + if (!draw(item.poolIndex, declared.categoryHash, 0, total)) { + return false; + } + } + } + return true; + } +}; + +bool resolve_item(definitions::View data, + const Context& context, + std::uint16_t itemIndex, + std::uint32_t quantity, + Result& result, + Resolution& failure) noexcept { + result = {}; + if (itemIndex >= data.items.size() || quantity == 0 || quantity > kMaximumQuantity) { + return refuse(context, "reward_shape"); + } + const auto& item = data.items[itemIndex]; + if (item.definitionHash == 0) { + return refuse(context, "item_unavailable"); + } + Result staged{}; + // Stored engrams retain their wrapper until an opening transaction. + if (item.poolIndex == definitions::kAbsent + || (item.flags & definitions::kOpenOnAcquisition) == 0) { + staged.grants[0].itemIndex = itemIndex; + staged.grants[0].quantity = static_cast(quantity); + staged.count = 1; + } else { + if (quantity != 1) { + return refuse(context, "wrapper_quantity"); + } + std::uint64_t wrapperSeed = context.seed; + AcquiredFlags acquiredFlags{}; + bool excludedByUnlocks = false; + bool leafDrawn = false; + bool leafFlagAcquired = false; + Resolver resolver{data, + context, + staged, + context.seed, + wrapperSeed, + acquiredFlags, + excludedByUnlocks, + leafDrawn, + leafFlagAcquired, + failure}; + if (!resolver.open_wrapper(item)) { + return false; + } + if (staged.count == 0 && !leafFlagAcquired) { + failure = + excludedByUnlocks && !leafDrawn ? Resolution::ineligible : Resolution::refused; + return refuse(context, "empty_reward"); + } + if (!resolver.acquire_flag(item, true)) { + return false; + } + } + result = staged; + if (context.refusal != nullptr) { + *context.refusal = nullptr; + } + return true; +} + +} // namespace + +bool eligible(std::span instructions, + const Context& context, + bool& result) noexcept { + return condition(instructions, context, result); +} + +Resolution resolve(const Context& context, + std::uint16_t itemIndex, + std::uint32_t quantity, + Result& result) noexcept { + struct Request { + const Context& context; + std::uint16_t item; + std::uint32_t quantity; + Result& result; + Resolution failure; + } request{context, itemIndex, quantity, result, Resolution::refused}; + result = {}; + if (context.refusal != nullptr) { + *context.refusal = "reward_item"; + } + if (build_data::read_reward_definitions( + &request, [](void* raw, definitions::View data) noexcept { + auto& value = *static_cast(raw); + return resolve_item( + data, value.context, value.item, value.quantity, value.result, value.failure); + })) { + return Resolution::resolved; + } + return request.failure; +} + +} // namespace sunrise::state::reward_resolution diff --git a/Sunrise/src/state/rewards/reward_resolver.h b/Sunrise/src/state/rewards/reward_resolver.h new file mode 100644 index 000000000..27c151371 --- /dev/null +++ b/Sunrise/src/state/rewards/reward_resolver.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include "../account/account_state.h" +#include "../build_data/rewards/definition.h" +#include "../unlocks/definition.h" + +namespace sunrise::state::reward_resolution { + +/** One item a resolved reward grants, with the socket overrides its row carries. */ +struct Grant { + std::uint16_t itemIndex{build_data::rewards::kAbsent}; + std::int32_t quantity{}; + std::array sockets{}; + std::size_t socketCount{}; +}; + +/** Every grant one resolution produces, in draw order. */ +struct Result { + std::array grants{}; + std::size_t count{}; + /** Consumed wrappers acquire flags without occupying inventory grant rows. */ + std::bitset wrapperFlags{}; +}; + +/** The seed belongs to the prepared server transaction, so validation repeats the same draw. */ +struct Context { + const unlocks::Table& unlocks; + CharacterClass characterClass{}; + std::uint64_t seed{}; + /** Optional refusal reason; points to a static diagnostic label. */ + const char** refusal{}; +}; + +/** Evaluates one bound condition; an empty condition is always eligible. */ +[[nodiscard]] bool eligible(std::span instructions, + const Context& context, + bool& result) noexcept; + +/** Ineligible: the saved unlocks left nothing to draw. Exceeded: this draw outgrew a bound. */ +enum class Resolution : std::uint8_t { resolved, ineligible, exceeded, refused }; + +/** Plans an acquisition without changing State; retains wrappers opened separately. */ +[[nodiscard]] Resolution resolve(const Context& context, + std::uint16_t itemIndex, + std::uint32_t quantity, + Result& result) noexcept; + +} // namespace sunrise::state::reward_resolution diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 892727e80..66e17621d 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -5,12 +5,19 @@ #include #include #include -#include +#include #include "../build_data/items/quest_initialization.h" #include "../build_data/records/definition.h" +#include "../build_data/rewards/definition.h" #include "../build_data/vendors/definition.h" +#include "../unlocks/definition.h" #include "state.h" +#include "state_vendor_reputation_runtime.h" + +namespace sunrise::state::unlocks { +struct Table; +} namespace sunrise::state::account::settings { @@ -20,6 +27,10 @@ struct SettingsDelta; namespace sunrise::state { +/** Which prepare path grants one installed item. */ +enum class ItemGrantRoute : std::uint8_t { unavailable, quest, profile, reward }; +/** Chooses the prepare path from the item's wrapper, quest setup and bucket. */ +[[nodiscard]] ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept; /** * Assigns runtime SOIDs only to installed profile mod/shader rows which are socket action sources. * Currency, material, and consumable profile rows remain canonically non-instanced. @@ -199,34 +210,23 @@ struct PendingProfileItemAcquisition { bool prepared{}; }; -/** Prepared fixed package expansion kept private until every object and response byte fits. */ -struct PendingDirectItemBundle { - CharacterState beforeCharacter{}; - CharacterState afterCharacter{}; - std::uint64_t accountSoid{}; - std::uint64_t characterSoid{}; - std::uint64_t firstInstanceSoid{}; - std::uint32_t sourceDefinitionHash{}; - std::size_t characterIndex{}; - std::size_t expectedInventoryCount{}; - std::size_t itemCount{}; - bool prepared{}; -}; - -/** Shared batch capacity covers both Triumph rewards and the nine-row Season package. */ -inline constexpr std::size_t kRecordRewardGrantCapacity = 9; +/** Shared batch capacity bounds one reward transaction. */ +inline constexpr std::size_t kRecordRewardGrantCapacity = build_data::rewards::kGrantCapacity; static_assert(kRecordRewardGrantCapacity >= build_data::records::kRewardPerRecordCapacity); /** One direct item requested by a record reward policy. */ struct DirectRecordReward { std::uint16_t itemDefinitionIndex{}; std::int32_t quantity{}; + std::span sockets{}; + bool acquireUnlock{}; }; enum class RecordRewardKind : std::uint8_t { characterInstance, characterStack, profileStack, + accountUnlock, }; /** Native row identity of one item inside a prepared record-reward batch. */ @@ -240,6 +240,14 @@ struct PreparedRecordReward { std::uint16_t inventoryRow{}; RecordRewardKind kind{}; bool appendedProfileResident{}; + std::uint16_t acquiredFlag{build_data::rewards::kAbsent}; + std::uint8_t previousFlag{}; +}; + +/** One consumed wrapper's acquisition flag and the value its preparation observed. */ +struct PreparedRewardFlag { + std::uint16_t index{}; + std::uint8_t previousValue{}; }; /** A reward grant that claims no record carries this instead of a record row. */ @@ -254,6 +262,8 @@ struct PendingRecordRewardGrant { std::array afterProfileItems{}; std::array rewards{}; + /** Sorted acquisition flags from wrappers which opened without remaining in inventory. */ + std::vector wrapperFlags; /** Record claimed with this grant, already written to the banks, or the unclaimed row. */ std::uint16_t claimedRecordIndex{kUnclaimedRecordIndex}; std::uint64_t accountSoid{}; @@ -262,17 +272,18 @@ struct PendingRecordRewardGrant { std::size_t beforeProfileItemCount{}; std::size_t afterProfileItemCount{}; std::size_t rewardCount{}; + VendorRankRewardClaim vendorReward{}; bool prepared{}; }; -/** One uncommitted Season reward and the exact native row or bundle it will claim. */ +/** How one reward preparation ended. */ +enum class RewardPreparation : std::uint8_t { prepared, deferred, unresolvable }; + +/** One uncommitted Season reward, the native row it will claim, and the seed of its draw. */ struct PendingSeasonPassReward { - std::variant - grant{}; + PendingRecordRewardGrant grant{}; std::uint32_t sourceDefinitionHash{}; + std::uint64_t seed{}; std::uint16_t rewardIndex{}; bool prepared{}; }; @@ -537,17 +548,30 @@ prepare_item_acquisition(std::uint16_t collectibleIndex, [[nodiscard]] bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex, PendingItemAcquisition& mutation) noexcept; -/** Prepares one fixed wrapper expansion without changing account State. */ -[[nodiscard]] bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash, - std::span itemDefinitionIndices, - PendingDirectItemBundle& mutation) noexcept; - -/** Builds the full account after-image while a prepared bundle remains current. */ -[[nodiscard]] bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation, - AccountState& after) noexcept; - /** Atomically commits one prepared reward grant and its durable Season claim. */ [[nodiscard]] bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept; +/** + * Resolves an installed item or wrapper for the selected character. + * @param seed Caller-drawn entropy for any wrapper draws. + * @param refusal Receives a static reason on failure. + */ +[[nodiscard]] RewardPreparation prepare_item_reward(std::uint16_t itemIndex, + std::uint32_t quantity, + std::uint64_t seed, + PendingRecordRewardGrant& mutation, + const char** refusal = nullptr) noexcept; +/** + * Prepares an eligible native pass row without writing its claim or acquisition flags. + * @param seed Caller-drawn entropy; the commit replays the same draw from it. + * @param refusal Receives a static reason on failure. + */ +[[nodiscard]] bool prepare_season_pass_reward(std::uint16_t rewardIndex, + std::uint64_t seed, + PendingSeasonPassReward& mutation, + const char** refusal = nullptr) noexcept; +/** Applies a prepared grant's acquisition flags to the saved banks; false if one moved. */ +[[nodiscard]] bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, + unlocks::Table& after) noexcept; /** Atomically commits one prepared Triumph reward and its durable record claim. */ [[nodiscard]] bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept; @@ -557,11 +581,13 @@ prepare_item_acquisition(std::uint16_t collectibleIndex, * @param rewards Item rows the record grants. * @param claimedRecordIndex Record already claimed in the banks, or kUnclaimedRecordIndex. * @param mutation Receives the prepared grant. - * @return True when every row fits the account after-image. + * @return Prepared when every row fits the account after-image. */ -[[nodiscard]] bool prepare_record_reward_grant(std::span rewards, - std::uint16_t claimedRecordIndex, - PendingRecordRewardGrant& mutation) noexcept; +[[nodiscard]] RewardPreparation +prepare_record_reward_grant(std::span rewards, + std::uint16_t claimedRecordIndex, + PendingRecordRewardGrant& mutation, + const char** refusal = nullptr) noexcept; /** Builds the full account after-image while a record reward remains current. */ [[nodiscard]] bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation, @@ -765,7 +791,10 @@ inline constexpr std::uint16_t kArtifactUnlockProgressionIndex = 39; /** @return Seasonal XP published in the account progression bank. */ [[nodiscard]] std::int32_t seasonal_experience() noexcept; -/** Publishes every seasonal value the seeded XP and artifact ownership imply. */ +/** + * Publishes every seasonal value the seeded XP and artifact ownership imply. + * An owed earned-perk walk runs here too. + */ [[nodiscard]] bool seed_seasonal_progression() noexcept; /** @return One-based Season of Arrivals rank the published XP earns. */ @@ -775,7 +804,8 @@ inline constexpr std::uint16_t kArtifactUnlockProgressionIndex = 39; [[nodiscard]] std::uint16_t artifact_power_bonus() noexcept; /** - * Adds base XP to the seasonal lanes and republishes every value derived from the total. + * Adds base XP to the seasonal lanes and republishes every value derived from the total, + * including earned-perk flags when a rank is reached or a walk is owed. * @param amount Positive XP to grant. * @return False when the amount is not positive or the total would overflow. */ diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index 9acbf4823..b61f59f96 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -261,100 +261,6 @@ bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex, account, account, grantedDefinition.definitionHash, false, {.direct = true}, mutation); } -/** Prepares a fixed Season wrapper expansion without exposing a partial package. */ -bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash, - std::span itemDefinitionIndices, - PendingDirectItemBundle& mutation) noexcept { - mutation = {}; - build_data::season_pass::Package package{}; - if (!build_data::find_season_pass_package(sourceDefinitionHash, package) - || itemDefinitionIndices.size() != package.itemCount) { - return false; - } - - std::array hashes{}; - for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) { - const std::uint16_t definitionIndex = itemDefinitionIndices[index]; - build_data::items::Definition definition{}; - item_details::Definition detail{}; - inventory_buckets::Descriptor bucket{}; - if (!build_data::find_item_definition_index(definitionIndex, definition) - || definition.definitionHash != package.items[index] - || !build_data::find_configured_item_detail(definitionIndex, detail) - || detail.definitionIndex != definition.definitionIndex - || detail.definitionHash != definition.definitionHash - || detail.bucketId != definition.bucketId - || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced - || !detail.equipmentSlot.has_value() - || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket) - || bucket.arraySelector != inventory_buckets::ArraySelector::character) { - return false; - } - hashes[index] = definition.definitionHash; - } - - const AccountState account = account_snapshot(); - const std::size_t characterIndex = selected_character_index(account); - if (!account::valid(account) || characterIndex >= account.characterCount) { - return false; - } - const CharacterState& before = account.characters[characterIndex]; - if (before.nextInventorySerial - > static_cast((std::numeric_limits::max)()) - || itemDefinitionIndices.size() > before.inventory.values.size() - before.inventory.count - || itemDefinitionIndices.size() - > static_cast((std::numeric_limits::max)()) - - before.nextInventorySerial) { - return false; - } - - std::uint64_t firstSoid = 0; - if (!next_item_instance_soid(account, firstSoid) - || itemDefinitionIndices.size() - 1U - > (std::numeric_limits::max)() - firstSoid) { - return false; - } - - CharacterState after = before; - const std::int32_t level = acquisition_level(before); - for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) { - authored_inventory::Item granted{}; - granted.instanceSoid = firstSoid + index; - granted.definitionHash = hashes[index]; - granted.level = level; - granted.quantity = 1; - granted.mutationSerial = static_cast(after.nextInventorySerial++); - after.inventory.values[after.inventory.count++] = granted; - } - - AccountState candidate = account; - candidate.characters[characterIndex] = after; - family4_loadout::ResolvedLoadout resolved{}; - if (!account::valid(candidate) - || !family4_loadout::resolve(candidate, characterIndex, resolved)) { - return false; - } - for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) { - std::uint16_t row = 0; - std::uint8_t slot = 0; - if (!find_unequipped_row(resolved, firstSoid + index, row, slot)) { - return false; - } - } - - mutation.beforeCharacter = before; - mutation.afterCharacter = after; - mutation.accountSoid = account.primarySoid; - mutation.characterSoid = before.soid; - mutation.firstInstanceSoid = firstSoid; - mutation.sourceDefinitionHash = sourceDefinitionHash; - mutation.characterIndex = characterIndex; - mutation.expectedInventoryCount = before.inventory.count; - mutation.itemCount = itemDefinitionIndices.size(); - mutation.prepared = true; - return true; -} - /** * Takes the selected character's next inventory mutation serial under the State lock. * @param mutationSerial Receives the reserved serial; zero when nothing was reserved. @@ -475,80 +381,6 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept { && row == mutation.inventoryRow && slot == mutation.equipmentSlot; } -/** Rebuilds one package from installed policy and rejects any altered after-image. */ -[[nodiscard]] bool materialize_direct_item_bundle(const AccountState& current, - const PendingDirectItemBundle& mutation, - AccountState& after) noexcept { - build_data::season_pass::Package package{}; - std::uint64_t firstSoid = 0; - if (!mutation.prepared - || !build_data::find_season_pass_package(mutation.sourceDefinitionHash, package) - || mutation.itemCount != package.itemCount || mutation.accountSoid == 0 - || mutation.characterSoid == 0 || mutation.firstInstanceSoid == 0 - || mutation.characterIndex >= current.characterCount - || mutation.expectedInventoryCount >= authored_inventory::kCharacterItemCapacity - || current.primarySoid != mutation.accountSoid - || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter) - || !current.characters[mutation.characterIndex].selected - || current.characters[mutation.characterIndex].soid != mutation.characterSoid - || mutation.beforeCharacter.inventory.count != mutation.expectedInventoryCount - || mutation.itemCount - > mutation.beforeCharacter.inventory.values.size() - mutation.expectedInventoryCount - || mutation.beforeCharacter.nextInventorySerial - > static_cast((std::numeric_limits::max)()) - || mutation.itemCount > static_cast((std::numeric_limits::max)()) - - mutation.beforeCharacter.nextInventorySerial - || !next_item_instance_soid(current, firstSoid) || firstSoid != mutation.firstInstanceSoid - || mutation.itemCount - 1U > (std::numeric_limits::max)() - firstSoid) { - return false; - } - - CharacterState canonical = mutation.beforeCharacter; - const std::int32_t level = acquisition_level(canonical); - for (std::size_t index = 0; index < mutation.itemCount; ++index) { - build_data::items::Definition definition{}; - item_details::Definition detail{}; - inventory_buckets::Descriptor bucket{}; - if (!build_data::find_item_definition_hash(package.items[index], definition) - || !build_data::find_configured_item_detail(definition.definitionIndex, detail) - || detail.definitionHash != definition.definitionHash - || detail.definitionIndex != definition.definitionIndex - || detail.bucketId != definition.bucketId - || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced - || !detail.equipmentSlot.has_value() - || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket) - || bucket.arraySelector != inventory_buckets::ArraySelector::character) { - return false; - } - authored_inventory::Item granted{}; - granted.instanceSoid = firstSoid + index; - granted.definitionHash = package.items[index]; - granted.level = level; - granted.quantity = 1; - granted.mutationSerial = static_cast(canonical.nextInventorySerial++); - canonical.inventory.values[canonical.inventory.count++] = granted; - } - if (!same_character(canonical, mutation.afterCharacter)) { - return false; - } - - after = current; - after.characters[mutation.characterIndex] = canonical; - family4_loadout::ResolvedLoadout resolved{}; - if (!account::valid(after) - || !family4_loadout::resolve(after, mutation.characterIndex, resolved)) { - return false; - } - for (std::size_t index = 0; index < mutation.itemCount; ++index) { - std::uint16_t row = 0; - std::uint8_t slot = 0; - if (!find_unequipped_row(resolved, firstSoid + index, row, slot)) { - return false; - } - } - return true; -} - } // namespace runtime::detail /** @@ -579,13 +411,6 @@ bool preview_item_acquisition(const PendingItemAcquisition& mutation, return true; } -/** Produces the full account after-image while a prepared package remains current. */ -bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation, - AccountState& after) noexcept { - after = {}; - return materialize_direct_item_bundle(account_snapshot(), mutation, after); -} - /** * Inventory and first-step state share one transaction; failure rolls both back. * @param mutation Prepared grant consumed on either success or failure. diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index 6918f54ae..ccdb20210 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -2,17 +2,17 @@ * Grants that no purchase pays for: season pass rewards, record rewards, and the * default emote collection. */ -#include - #include #include #include #include +#include "../../middleware/datagen/family4/account/layout.h" +#include "../../middleware/datagen/family4/character/layout.h" #include "../../middleware/datagen/family4/loadout/loadout_resolver.h" #include "../build_data/runtime.h" #include "../investment/store_internal.h" -#include "../progression/season_pass_reward_catalog.h" +#include "../rewards/reward_resolver.h" #include "../unlocks/unlocks_records.h" #include "runtime.h" #include "state_account_transaction_helpers.h" @@ -28,90 +28,490 @@ namespace family4_loadout = middleware::datagen::family4::loadout; namespace { +/** Prepares inventory rows and consumed-wrapper flags against the same account view. */ +[[nodiscard]] RewardPreparation +prepare_reward_grant(std::span rewards, + std::uint16_t claimedRecordIndex, + const std::bitset* wrapperFlags, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept; + [[nodiscard]] bool materialize_record_reward(const AccountState& current, const PendingRecordRewardGrant& mutation, AccountState& after) noexcept; -/** - * Checks that a staged grant is the one the authored reward row describes. - * @param reward Authored season pass reward row. - * @return False when the grant's item, quantity or bundle does not match the row. - */ +/** Each native change list must fit independently; unlock flags consume neither list. */ +[[nodiscard]] const char* +reward_change_refusal(std::span rewards) noexcept { + std::size_t characterChanges = 0; + std::size_t profileChanges = 0; + for (const auto& reward : rewards) { + switch (reward.kind) { + case RecordRewardKind::characterInstance: + case RecordRewardKind::characterStack: + if (++characterChanges + > middleware::datagen::family4::character::layout::kInventoryChangeRecordCapacity) { + return "character_change_capacity"; + } + break; + case RecordRewardKind::profileStack: + if (++profileChanges > middleware::datagen::family4::account::layout:: + kProfileInventoryChangeRecordCapacity) { + return "profile_change_capacity"; + } + break; + case RecordRewardKind::accountUnlock: + break; + default: + return "reward_kind"; + } + } + return nullptr; +} + +/** A reward overrides socket types, so adding an ordinary lane cannot shift a fixed roll. */ +[[nodiscard]] bool +apply_reward_sockets(const item_details::Definition& detail, + std::span overrides, + authored_inventory::Sockets& sockets, + const char** refusal = nullptr) noexcept { + const auto fail = [&](const char* reason) noexcept { + if (refusal != nullptr) { + *refusal = reason; + } + return false; + }; + if (overrides.empty()) { + return true; + } + if (detail.ordinarySocketCount > sockets.plugs.size()) { + return fail("socket_layout"); + } + authored_inventory::Sockets staged{}; + staged.policy = authored_inventory::SocketPolicy::authored; + staged.plugCount = detail.ordinarySocketCount; + for (std::size_t lane = 0; lane < staged.plugCount; ++lane) { + const auto index = detail.initialPlugIndices[lane]; + if (index == item_details::kUnavailableItemIndex) { + continue; + } + build_data::items::Definition plug{}; + if (!build_data::find_item_definition_index(index, plug)) { + return fail("socket_layout"); + } + staged.plugs[lane] = plug.definitionHash; + } + std::array replaced{}; + for (const auto& override : overrides) { + if (override.plugSet != build_data::rewards::kAbsent) { + return fail("socket_plug_set"); + } + if (override.rollSet != build_data::rewards::kAbsent) { + return fail("socket_roll_set"); + } + // Zero selection preserves the initial plug in every lane of the named type. + const bool preserve = + override.plugItem == build_data::rewards::kAbsent && override.selection == 0; + if (!preserve && override.selection != build_data::rewards::kFixedPlugSelection) { + return fail("socket_selection"); + } + build_data::items::Definition plug{}; + if (!preserve && !build_data::find_item_definition_index(override.plugItem, plug)) { + return fail("socket_layout"); + } + bool found = false; + for (std::size_t lane = 0; lane < staged.plugCount; ++lane) { + if (detail.socketTypes[lane] != override.socketType) { + continue; + } + if (replaced[lane] || (!preserve && found)) { + return fail("socket_layout"); + } + replaced[lane] = true; + found = true; + if (!preserve) { + staged.plugs[lane] = plug.definitionHash; + } + } + if (!found) { + return fail("socket_layout"); + } + } + if (!authored_inventory::valid(staged)) { + return fail("socket_layout"); + } + sockets = staged; + return true; +} + +[[nodiscard]] RewardPreparation prepare_resolved_reward(const reward_resolution::Result& resolved, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + std::array rows{}; + if (resolved.count > rows.size()) { + return RewardPreparation::unresolvable; + } + for (std::size_t i = 0; i < resolved.count; ++i) { + const auto& grant = resolved.grants[i]; + rows[i] = {grant.itemIndex, + grant.quantity, + std::span(grant.sockets).first(grant.socketCount), + true}; + } + return prepare_reward_grant(std::span(rows).first(resolved.count), + kUnclaimedRecordIndex, + &resolved.wrapperFlags, + mutation, + refusal); +} + +/** Finds the kept unsocketed grant of this item, or null. */ +[[nodiscard]] reward_resolution::Grant* kept_stack(reward_resolution::Result& resolved, + std::size_t kept, + std::uint16_t itemIndex) noexcept { + for (std::size_t prior = 0; prior < kept; ++prior) { + auto& grant = resolved.grants[prior]; + if (grant.itemIndex == itemIndex && grant.socketCount == 0) { + return &grant; + } + } + return nullptr; +} + +/** Folds repeat draws of a stackable item into its first draw; false when one stack overflows. */ +[[nodiscard]] bool consolidate_stacks(reward_resolution::Result& resolved) noexcept { + std::size_t kept = 0; + for (std::size_t index = 0; index < resolved.count; ++index) { + const reward_resolution::Grant grant = resolved.grants[index]; + build_data::items::Definition item{}; + item_details::Definition detail{}; + // A perk grants its account flag, never an inventory stack. + const bool inventoryStack = + grant.socketCount == 0 && build_data::find_item_definition_index(grant.itemIndex, item) + && item.bucketId != inventory_buckets::kPerkBucketId + && build_data::find_configured_item_detail(grant.itemIndex, detail) + && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable; + auto* const merged = inventoryStack ? kept_stack(resolved, kept, grant.itemIndex) : nullptr; + if (merged == nullptr) { + resolved.grants[kept++] = grant; + continue; + } + if (static_cast(merged->quantity) + grant.quantity > detail.maxStackSize) { + return false; + } + merged->quantity += grant.quantity; + } + resolved.count = kept; + return true; +} + +/** Classifies a catalog refusal: a verdict unless a catalog was cleared meanwhile. */ +[[nodiscard]] RewardPreparation catalog_refusal(const char*& reason) noexcept { + if (build_data::item_definitions_ready() && build_data::reward_definitions_ready()) { + return RewardPreparation::unresolvable; + } + reason = "not_ready"; + return RewardPreparation::deferred; +} + +/** Wrappers and direct account perks bypass quest initialization. */ +bool uses_reward_definition(const build_data::items::Definition& item) noexcept { + build_data::rewards::Item reward{}; + if (!build_data::find_reward_item(item.definitionIndex, reward)) { + return false; + } + return reward.poolIndex != build_data::rewards::kAbsent + || (item.bucketId == inventory_buckets::kPerkBucketId + && reward.acquiredFlag != build_data::rewards::kAbsent); +} + +enum class PassResolution { claim, replay }; + +[[nodiscard]] bool resolve_pass(const build_data::season_pass::Reward& reward, + const AccountState& account, + std::uint64_t seed, + reward_resolution::Result& result, + PassResolution resolution, + const char** reason = nullptr) noexcept { + const auto character = selected_character_index(account); + unlocks::Table flags{}; + build_data::rewards::Item source{}; + if (character >= account.characterCount + || !investment::store::read_unlocks(flags, static_cast(character)) + || !build_data::find_reward_item(reward.itemIndex, source) + || source.definitionHash != reward.itemHash || reward.socketCount > reward.sockets.size() + || reward.conditionCount > reward.condition.size()) { + return false; + } + if (reason != nullptr) { + *reason = "reward_condition"; + } + if (resolution == PassResolution::replay && reward.claimFlagIndex < flags.accountFlags.size()) { + flags.accountFlags[reward.claimFlagIndex] = unlocks::kFlagClear; + } + bool enabled = false; + if (!reward_resolution::eligible( + std::span(reward.condition).first(reward.conditionCount), + {flags, account.characters[character].characterClass, seed, reason}, + enabled) + || !enabled) { + return false; + } + if (reward_resolution::resolve( + {flags, account.characters[character].characterClass, seed, reason}, + reward.itemIndex, + reward.quantity, + result) + != reward_resolution::Resolution::resolved) { + return false; + } + if (!consolidate_stacks(result)) { + if (reason != nullptr) { + *reason = "stack_capacity"; + } + return false; + } + if (reward.socketCount != 0) { + if (result.count != 1 || result.grants[0].itemIndex != reward.itemIndex) { + if (reason != nullptr) { + *reason = "socket_owner"; + } + return false; + } + result.grants[0].sockets = reward.sockets; + result.grants[0].socketCount = reward.socketCount; + } + return true; +} + [[nodiscard]] bool reward_matches(const build_data::season_pass::Reward& reward, const PendingSeasonPassReward& mutation) noexcept { - if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash) { + const auto* grant = &mutation.grant; + reward_resolution::Result expected{}; + if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash + || !resolve_pass( + reward, account_snapshot(), mutation.seed, expected, PassResolution::replay) + || expected.count != grant->rewardCount + || expected.wrapperFlags.count() != grant->wrapperFlags.size()) { return false; } - if (const auto* item = std::get_if(&mutation.grant)) { - if (reward.quantity != 1) { + for (const auto& flag : grant->wrapperFlags) { + if (flag.index >= expected.wrapperFlags.size() || !expected.wrapperFlags[flag.index]) { return false; } - if (reward.itemHash != progression::season_pass::kLegendaryEngramHash - && reward.itemHash != progression::season_pass::kExoticEngramHash) { - return item->acquiredDefinitionHash == reward.itemHash; - } - return progression::season_pass::contains_engram_reward( - reward.itemHash, - item->acquiredDefinitionHash, - static_cast(item->afterCharacter.characterClass)); - } - if (const auto* profile = std::get_if(&mutation.grant)) { - return profile->acquiredDefinitionHash == reward.itemHash - && profile->acquiredQuantity - profile->previousQuantity - == static_cast(reward.quantity); - } - if (const auto* bundle = std::get_if(&mutation.grant)) { - build_data::season_pass::Package package{}; - return reward.quantity == 1 && bundle->sourceDefinitionHash == reward.itemHash - && build_data::find_season_pass_package(reward.itemHash, package); - } - const auto* resources = std::get_if(&mutation.grant); - if (resources == nullptr || reward.quantity != 1 - || reward.itemHash != progression::season_pass::kDestinationResourceBundleHash - || resources->rewardCount != progression::season_pass::kDestinationResourceHashes.size()) { - return false; } - for (std::size_t index = 0; index < resources->rewardCount; ++index) { - if (resources->rewards[index].definitionHash - != progression::season_pass::kDestinationResourceHashes[index] - || resources->rewards[index].quantity - != progression::season_pass::kDestinationResourceQuantity) { + for (std::size_t i = 0; i < expected.count; ++i) { + const auto& planned = expected.grants[i]; + const auto& prepared = grant->rewards[i]; + build_data::items::Definition definition{}; + build_data::rewards::Item source{}; + if (!build_data::find_item_definition_index(planned.itemIndex, definition) + || !build_data::find_reward_item(planned.itemIndex, source) + || prepared.definitionHash != definition.definitionHash + || prepared.quantity != planned.quantity + || prepared.acquiredFlag != source.acquiredFlag) { return false; } + if (planned.socketCount != 0) { + item_details::Definition detail{}; + authored_inventory::Sockets sockets{}; + if (prepared.kind != RecordRewardKind::characterInstance + || prepared.stateIndex >= grant->afterCharacter.inventory.count + || !build_data::find_configured_item_detail(planned.itemIndex, detail) + || !apply_reward_sockets( + detail, std::span(planned.sockets).first(planned.socketCount), sockets)) { + return false; + } + const auto& installed = + grant->afterCharacter.inventory.values[prepared.stateIndex].sockets; + if (installed.policy != sockets.policy || installed.plugCount != sockets.plugCount + || installed.plugs != sockets.plugs) { + return false; + } + } } return true; } } // namespace -/** Commits a reward and its claim together after every outbound byte has been staged. */ -bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept { - const PendingConsumption consume{mutation}; +bool prepare_season_pass_reward(std::uint16_t rewardIndex, + std::uint64_t seed, + PendingSeasonPassReward& mutation, + const char** refusal) noexcept { + const char* unused = nullptr; + auto& reason = refusal != nullptr ? *refusal : unused; + reason = "reward_index"; + mutation = {}; + const std::lock_guard lock(investment::store::g_mutex); build_data::season_pass::Reward reward{}; - if (!build_data::find_season_pass_reward(mutation.rewardIndex, reward) - || !reward_matches(reward, mutation)) { - revoke_season_pass_reward(mutation.rewardIndex); + if (!build_data::find_season_pass_reward(rewardIndex, reward)) { + return false; + } + reason = "rank"; + if (reward.requiredRank > seasonal_rank()) { + return false; + } + reason = "already_claimed"; + if (season_pass_reward_claimed(rewardIndex)) { + return false; + } + mutation.seed = seed; + reward_resolution::Result resolved{}; + reason = "reward_condition"; + if (!resolve_pass( + reward, account_snapshot(), mutation.seed, resolved, PassResolution::claim, &reason)) { return false; } + reason = "reward_placement"; + if (prepare_resolved_reward(resolved, mutation.grant, &reason) != RewardPreparation::prepared) { + return false; + } + reason = nullptr; + mutation.sourceDefinitionHash = reward.itemHash; + mutation.rewardIndex = rewardIndex; + mutation.prepared = true; + return true; +} - investment::store::g_mutex.lock(); - AccountState after{}; +ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept { + build_data::items::Definition item{}; + if (!build_data::find_item_definition_index(itemIndex, item)) { + return ItemGrantRoute::unavailable; + } + if (uses_reward_definition(item)) { + return ItemGrantRoute::reward; + } + if (item.questInitialization.scope != build_data::items::QuestInitialization::Scope::none) { + return ItemGrantRoute::quest; + } + item_details::Definition detail{}; + inventory_buckets::Descriptor bucket{}; + if (!build_data::find_configured_item_detail(itemIndex, detail) + || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) { + return ItemGrantRoute::unavailable; + } + if (bucket.arraySelector == inventory_buckets::ArraySelector::profile + && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable) { + return ItemGrantRoute::profile; + } + return bucket.arraySelector == inventory_buckets::ArraySelector::character + ? ItemGrantRoute::reward + : ItemGrantRoute::unavailable; +} + +RewardPreparation prepare_item_reward(std::uint16_t itemIndex, + std::uint32_t quantity, + std::uint64_t seed, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + const char* unused = nullptr; + auto& reason = refusal != nullptr ? *refusal : unused; + reason = "not_ready"; + mutation = {}; + // Unpublished domains decide nothing; retry later. + if (!build_data::item_definitions_ready() || !build_data::reward_definitions_ready()) { + return RewardPreparation::deferred; + } + reason = "item_definition"; + const std::lock_guard lock(investment::store::g_mutex); + build_data::items::Definition item{}; + if (!build_data::find_item_definition_index(itemIndex, item)) { + return catalog_refusal(reason); + } + reason = "selected_character"; + const AccountState account = account_snapshot(); + const auto character = selected_character_index(account); + unlocks::Table flags{}; + if (character >= account.characterCount + || !investment::store::read_unlocks(flags, static_cast(character))) { + return RewardPreparation::deferred; + } + reward_resolution::Result resolved{}; + const auto resolution = reward_resolution::resolve( + {flags, account.characters[character].characterClass, seed, &reason}, + itemIndex, + quantity, + resolved); + // Unlocks can change and another draw can fit, so these are retried, not retired. + if (resolution == reward_resolution::Resolution::ineligible + || resolution == reward_resolution::Resolution::exceeded) { + return RewardPreparation::deferred; + } + if (resolution != reward_resolution::Resolution::resolved) { + return catalog_refusal(reason); + } + reason = "stack_capacity"; + if (!consolidate_stacks(resolved)) { + return RewardPreparation::deferred; + } + reason = "reward_placement"; + return prepare_resolved_reward(resolved, mutation, &reason); +} + +bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, + unlocks::Table& after) noexcept { + const std::lock_guard lock(investment::store::g_mutex); + if (!mutation.prepared || mutation.rewardCount > mutation.rewards.size() + || !investment::store::read_unlocks(after, static_cast(mutation.characterIndex))) { + return false; + } + for (std::size_t i = 0; i < mutation.rewardCount; ++i) { + const auto& reward = mutation.rewards[i]; + if (reward.acquiredFlag == build_data::rewards::kAbsent) { + continue; + } + if (reward.acquiredFlag >= after.accountFlags.size() + || after.accountFlags[reward.acquiredFlag] != reward.previousFlag) { + return false; + } + } + for (std::size_t i = 0; i < mutation.wrapperFlags.size(); ++i) { + const auto& flag = mutation.wrapperFlags[i]; + if (flag.index >= after.accountFlags.size() || flag.previousValue > unlocks::kFlagSet + || (i != 0 && mutation.wrapperFlags[i - 1].index >= flag.index) + || after.accountFlags[flag.index] != flag.previousValue) { + return false; + } + } + for (const auto& flag : mutation.wrapperFlags) { + after.accountFlags[flag.index] = unlocks::kFlagSet; + } + for (std::size_t i = 0; i < mutation.rewardCount; ++i) { + const auto flag = mutation.rewards[i].acquiredFlag; + if (flag != build_data::rewards::kAbsent) { + after.accountFlags[flag] = unlocks::kFlagSet; + } + } + const auto& claim = mutation.vendorReward; + if (claim.beforeCredits != 0) { + if (claim.rankCreditRow >= after.characterObjectValues.size() + || !vendor_rank_reward_current(claim) + || after.characterObjectValues[claim.rankCreditRow] != claim.beforeCredits) { + return false; + } + after.characterObjectValues[claim.rankCreditRow] = claim.beforeCredits - 1; + } + return true; +} + +/** Revalidates the prepared reward under the lock that commits its inventory and flags. */ +bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept { + if (!mutation.prepared) { + return false; + } + const PendingConsumption consume{mutation}; bool ready = false; - if (const auto* item = std::get_if(&mutation.grant)) { - ready = materialize_item_acquisition(investment::store::account(), *item, after); - } else if (const auto* profile = std::get_if(&mutation.grant)) { - ready = materialize_profile_acquisition(investment::store::account(), *profile, after); - } else if (const auto* bundle = std::get_if(&mutation.grant)) { - ready = materialize_direct_item_bundle(investment::store::account(), *bundle, after); - } else if (const auto* resources = std::get_if(&mutation.grant)) { - ready = materialize_record_reward(investment::store::account(), *resources, after); - } - if (ready) { - ready = investment::store::write_account(after); + { + investment::store::Transaction transaction; + build_data::season_pass::Reward reward{}; + ready = transaction.ready() + && build_data::find_season_pass_reward(mutation.rewardIndex, reward) + && season_pass_reward_claimed(mutation.rewardIndex) + && reward.requiredRank <= seasonal_rank() && reward_matches(reward, mutation) + && commit_record_reward(mutation.grant) && transaction.commit(); } - investment::store::g_mutex.unlock(); if (!ready) { - // The claim was written when the reward was prepared, so a refused install undoes it. revoke_season_pass_reward(mutation.rewardIndex); } return ready; @@ -128,9 +528,11 @@ namespace { [[nodiscard]] bool materialize_record_reward(const AccountState& current, const PendingRecordRewardGrant& mutation, AccountState& after) noexcept { - if (!mutation.prepared || mutation.rewardCount == 0 - || mutation.rewardCount > mutation.rewards.size() || mutation.accountSoid == 0 - || mutation.characterSoid == 0 || mutation.characterIndex >= current.characterCount + if (!mutation.prepared || (mutation.rewardCount == 0 && mutation.wrapperFlags.empty()) + || mutation.rewardCount > mutation.rewards.size() + || reward_change_refusal(std::span(mutation.rewards).first(mutation.rewardCount)) != nullptr + || mutation.accountSoid == 0 || mutation.characterSoid == 0 + || mutation.characterIndex >= current.characterCount || current.primarySoid != mutation.accountSoid || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter) || !same_profile_inventory( @@ -157,8 +559,21 @@ namespace { inventory_buckets::Descriptor bucket{}; if (reward.definitionHash == authored_inventory::kNoDefinitionHash || reward.quantity <= 0 || reward.afterQuantity < reward.quantity || reward.mutationSerial < 0 - || !build_data::find_item_definition_hash(reward.definitionHash, item) - || !build_data::find_configured_item_detail(item.definitionIndex, detail) + || !build_data::find_item_definition_hash(reward.definitionHash, item)) { + return false; + } + if (reward.kind == RecordRewardKind::accountUnlock) { + build_data::rewards::Item source{}; + if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid != 0 + || item.bucketId != build_data::inventory::buckets::kPerkBucketId + || !build_data::find_reward_item(item.definitionIndex, source) + || source.acquiredFlag == build_data::rewards::kAbsent + || source.acquiredFlag != reward.acquiredFlag) { + return false; + } + continue; + } + if (!build_data::find_configured_item_detail(item.definitionIndex, detail) || detail.definitionIndex != item.definitionIndex || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) { @@ -166,7 +581,7 @@ namespace { } if (reward.kind == RecordRewardKind::characterInstance) { if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid == 0 - || reward.appendedProfileResident || !detail.equipmentSlot.has_value() + || reward.appendedProfileResident || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced || bucket.arraySelector != inventory_buckets::ArraySelector::character @@ -225,52 +640,146 @@ namespace { return true; } -} // namespace +/** Captures only flags carried by consumed wrappers; storage failure remains retryable. */ +[[nodiscard]] bool prepare_wrapper_flags(const std::bitset* flags, + PendingRecordRewardGrant& mutation, + const char*& reason) noexcept { + if (flags == nullptr) { + return true; + } + try { + mutation.wrapperFlags.reserve(flags->count()); + for (std::size_t index = 0; index < flags->size(); ++index) { + if (!(*flags)[index]) { + continue; + } + reason = "wrapper_acquisition_flag"; + std::int32_t before = 0; + if (!investment::store::read_unlock(investment::store::Bank::accountFlags, + static_cast(index), + before) + || before < 0 || before > unlocks::kFlagSet) { + return false; + } + mutation.wrapperFlags.push_back( + {static_cast(index), static_cast(before)}); + } + } catch (...) { + reason = "wrapper_flag_storage"; + return false; + } + return true; +} -/** Prepares every reward over one cumulative account view. */ -bool prepare_record_reward_grant(std::span rewards, - std::uint16_t claimedRecordIndex, - PendingRecordRewardGrant& mutation) noexcept { +/** Prepares inventory rows and consumed-wrapper flags against the same account view. */ +RewardPreparation +prepare_reward_grant(std::span rewards, + std::uint16_t claimedRecordIndex, + const std::bitset* wrapperFlags, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + const char* unused = nullptr; + auto& reason = refusal != nullptr ? *refusal : unused; + reason = "reward_count"; mutation = {}; - if (rewards.empty() || rewards.size() > mutation.rewards.size()) { - return false; + if ((rewards.empty() && (wrapperFlags == nullptr || wrapperFlags->none())) + || rewards.size() > mutation.rewards.size()) { + return RewardPreparation::unresolvable; } + reason = "not_ready"; + // The loadout check needs these domains, so nothing is decided before they publish. + if (!build_data::item_definitions_ready() || !build_data::configured_item_details_ready() + || !build_data::inventory_bucket_descriptors_ready() + || !build_data::socket_entry_lists_ready()) { + return RewardPreparation::deferred; + } + reason = "account_state"; const AccountState account = account_snapshot(); const std::size_t characterIndex = selected_character_index(account); if (!account::valid(account) || !valid_profile_inventory(account) || characterIndex >= account.characterCount) { - return false; + return RewardPreparation::deferred; } + if (!prepare_wrapper_flags(wrapperFlags, mutation, reason)) { + return RewardPreparation::deferred; + } AccountState working = account; for (std::size_t index = 0; index < rewards.size(); ++index) { + reason = "item_identity"; const DirectRecordReward& requested = rewards[index]; build_data::items::Definition item{}; item_details::Definition detail{}; inventory_buckets::Descriptor bucket{}; if (requested.quantity <= 0 || !build_data::find_item_definition_index(requested.itemDefinitionIndex, item) - || !build_data::find_configured_item_detail(requested.itemDefinitionIndex, detail) + || item.questInitialization.scope + != build_data::items::QuestInitialization::Scope::none) { + return RewardPreparation::unresolvable; + } + PreparedRecordReward prepared{}; + prepared.definitionHash = item.definitionHash; + prepared.quantity = requested.quantity; + if (requested.acquireUnlock) { + reason = "acquisition_flag"; + // Only a resolved reward asks for its flag, so the graph is published. + build_data::rewards::Item source{}; + if (!build_data::find_reward_item(item.definitionIndex, source) + || source.definitionHash != item.definitionHash) { + return RewardPreparation::unresolvable; + } + prepared.acquiredFlag = source.acquiredFlag; + if (source.acquiredFlag != build_data::rewards::kAbsent) { + std::int32_t before = 0; + // The saved flag refuses here, not the definition. + if (!investment::store::read_unlock( + investment::store::Bank::accountFlags, source.acquiredFlag, before) + || before < 0 || before > unlocks::kFlagSet) { + return RewardPreparation::deferred; + } + prepared.previousFlag = static_cast(before); + } + } + if (item.bucketId == build_data::inventory::buckets::kPerkBucketId) { + reason = "perk_acquisition"; + if (prepared.acquiredFlag == build_data::rewards::kAbsent || requested.quantity != 1 + || !requested.sockets.empty()) { + return RewardPreparation::unresolvable; + } + prepared.kind = RecordRewardKind::accountUnlock; + prepared.afterQuantity = 1; + mutation.rewards[index] = prepared; + continue; + } + reason = "item_detail"; + if (!build_data::find_configured_item_detail(requested.itemDefinitionIndex, detail) || detail.definitionIndex != item.definitionIndex || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) { - return false; + return RewardPreparation::unresolvable; } + reason = "duplicate_stack"; if (detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable) { for (std::size_t prior = 0; prior < index; ++prior) { if (mutation.rewards[prior].definitionHash == item.definitionHash) { - return false; + return RewardPreparation::unresolvable; } } } - PreparedRecordReward prepared{}; - prepared.definitionHash = item.definitionHash; - prepared.quantity = requested.quantity; + reason = "socket_owner"; + if (!requested.sockets.empty() + && (bucket.arraySelector != inventory_buckets::ArraySelector::character + || detail.instancedDefinitionState + != item_details::InstancedDefinitionState::instanced)) { + return RewardPreparation::unresolvable; + } + reason = "inventory_destination"; if (bucket.arraySelector == inventory_buckets::ArraySelector::profile) { - if (detail.instancedDefinitionState - != item_details::InstancedDefinitionState::stackable) { - return false; + reason = "profile_capacity"; + if (detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable + || requested.quantity > detail.maxStackSize) { + return RewardPreparation::unresolvable; } PendingProfileItemAcquisition staged{}; const bool actionSource = @@ -283,7 +792,7 @@ bool prepare_record_reward_grant(std::span rewards, requested.quantity, {.direct = true}, staged)) { - return false; + return RewardPreparation::deferred; } working.profileItems = staged.afterItems; working.profileItemCount = staged.afterItemCount; @@ -296,13 +805,22 @@ bool prepare_record_reward_grant(std::span rewards, } else if (bucket.arraySelector == inventory_buckets::ArraySelector::character && detail.instancedDefinitionState == item_details::InstancedDefinitionState::instanced) { - if (requested.quantity != 1 || !detail.equipmentSlot.has_value()) { - return false; + reason = "instance_quantity"; + if (requested.quantity != 1) { + return RewardPreparation::unresolvable; } + reason = "instance_capacity"; PendingItemAcquisition staged{}; if (!finalize_item_acquisition( working, working, item.definitionHash, false, {.direct = true}, staged)) { - return false; + return RewardPreparation::deferred; + } + if (!apply_reward_sockets( + detail, + requested.sockets, + staged.afterCharacter.inventory.values[staged.inventoryIndex].sockets, + &reason)) { + return RewardPreparation::unresolvable; } working.characters[characterIndex] = staged.afterCharacter; prepared.instanceSoid = staged.acquiredInstanceSoid; @@ -316,11 +834,14 @@ bool prepare_record_reward_grant(std::span rewards, && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable && !detail.equipmentSlot.has_value()) { + reason = "character_stack_capacity"; CharacterState& character = working.characters[characterIndex]; - if (requested.quantity > detail.maxStackSize - || character.nextInventorySerial - >= static_cast((std::numeric_limits::max)())) { - return false; + if (requested.quantity > detail.maxStackSize) { + return RewardPreparation::unresolvable; + } + if (character.nextInventorySerial + >= static_cast((std::numeric_limits::max)())) { + return RewardPreparation::deferred; } std::size_t stackIndex = character.stacks.count; for (std::size_t candidate = 0; candidate < character.stacks.count; ++candidate) { @@ -334,7 +855,7 @@ bool prepare_record_reward_grant(std::span rewards, || (!appended && character.stacks.values[stackIndex].quantity > detail.maxStackSize - requested.quantity)) { - return false; + return RewardPreparation::deferred; } auto& stack = character.stacks.values[stackIndex]; if (appended) { @@ -348,15 +869,22 @@ bool prepare_record_reward_grant(std::span rewards, prepared.mutationSerial = stack.mutationSerial; prepared.kind = RecordRewardKind::characterStack; } else { - return false; + return RewardPreparation::unresolvable; } mutation.rewards[index] = prepared; } + reason = reward_change_refusal(std::span(mutation.rewards).first(rewards.size())); + if (reason != nullptr) { + // Only a wrapper draw can exceed a list, and its next draw may fit. + return RewardPreparation::deferred; + } + reason = "loadout"; + // A full native bucket rejects the whole loadout; retry later. family4_loadout::ResolvedLoadout loadout{}; if (!account::valid(working) || !valid_profile_inventory(working) || !family4_loadout::resolve(working, characterIndex, loadout)) { - return false; + return RewardPreparation::deferred; } mutation.beforeCharacter = account.characters[characterIndex]; mutation.afterCharacter = working.characters[characterIndex]; @@ -369,8 +897,19 @@ bool prepare_record_reward_grant(std::span rewards, mutation.beforeProfileItemCount = account.profileItemCount; mutation.afterProfileItemCount = working.profileItemCount; mutation.rewardCount = rewards.size(); + reason = nullptr; mutation.prepared = true; - return true; + return RewardPreparation::prepared; +} + +} // namespace + +/** Prepares every direct reward over one cumulative account view. */ +RewardPreparation prepare_record_reward_grant(std::span rewards, + std::uint16_t claimedRecordIndex, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + return prepare_reward_grant(rewards, claimedRecordIndex, nullptr, mutation, refusal); } bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation, @@ -381,14 +920,40 @@ bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation, /** Commits the shared reward after-image and claim together. */ bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept { + if (!mutation.prepared) { + return false; + } const PendingConsumption consume{mutation}; - investment::store::g_mutex.lock(); - AccountState after{}; - bool ready = materialize_record_reward(investment::store::account(), mutation, after); - if (ready) { - ready = investment::store::write_account(after); + bool ready = false; + { + investment::store::Transaction transaction; + AccountState after{}; + unlocks::Table flags{}; + ready = transaction.ready() + && materialize_record_reward(investment::store::account(), mutation, after) + && preview_reward_unlocks(mutation, flags) + && investment::store::write_account(after); + for (std::size_t i = 0; ready && i < mutation.rewardCount; ++i) { + const auto flag = mutation.rewards[i].acquiredFlag; + if (flag != build_data::rewards::kAbsent) { + ready = investment::store::write_unlock( + investment::store::Bank::accountFlags, flag, unlocks::kFlagSet); + } + } + for (const auto& flag : mutation.wrapperFlags) { + if (!ready) { + break; + } + ready = investment::store::write_unlock( + investment::store::Bank::accountFlags, flag.index, unlocks::kFlagSet); + } + if (ready && mutation.vendorReward.beforeCredits > 0) { + ready = investment::store::write_unlock(investment::store::Bank::characterObjectValues, + mutation.vendorReward.rankCreditRow, + mutation.vendorReward.beforeCredits - 1); + } + ready = ready && transaction.commit(); } - investment::store::g_mutex.unlock(); if (!ready && mutation.claimedRecordIndex != kUnclaimedRecordIndex) { // The claim was written when the reward was prepared, so a refused install undoes it. unlocks::records::revoke(mutation.claimedRecordIndex); diff --git a/Sunrise/src/state/runtime/state_account_transaction_helpers.h b/Sunrise/src/state/runtime/state_account_transaction_helpers.h index 5b77c5039..a31d2be01 100644 --- a/Sunrise/src/state/runtime/state_account_transaction_helpers.h +++ b/Sunrise/src/state/runtime/state_account_transaction_helpers.h @@ -166,13 +166,6 @@ finalize_profile_item_acquisition(const AccountState& account, [[nodiscard]] bool materialize_item_acquisition(const AccountState& current, const PendingItemAcquisition& mutation, AccountState& after) noexcept; -/** - * Rebuilds one package from installed policy and rejects any altered after-image. - * @return False when the account moved or the rebuilt character differs from the mutation. - */ -[[nodiscard]] bool materialize_direct_item_bundle(const AccountState& current, - const PendingDirectItemBundle& mutation, - AccountState& after) noexcept; [[nodiscard]] bool native_equipment_slot(const account::inventory::Item& item, std::uint8_t& slot) noexcept; [[nodiscard]] bool semantic_equipment_slot(std::uint8_t nativeSlot, diff --git a/Sunrise/src/state/runtime/state_progression_runtime.cpp b/Sunrise/src/state/runtime/state_progression_runtime.cpp index ffe609608..c8585c66b 100644 --- a/Sunrise/src/state/runtime/state_progression_runtime.cpp +++ b/Sunrise/src/state/runtime/state_progression_runtime.cpp @@ -8,14 +8,17 @@ #include #include +#include "../../core/logging/log.h" #include "../build_data/runtime.h" #include "../investment/investment.h" #include "../investment/store_internal.h" #include "../progression/season_pass_reward_catalog.h" +#include "../rewards/reward_resolver.h" #include "../unlocks/definition.h" #include "../unlocks/unlocks_runtime.h" #include "runtime.h" #include "state.h" +#include "state_account_transaction_helpers.h" #include "storage/internal.h" namespace sunrise::state { @@ -91,6 +94,13 @@ using SaleRows = std::array( + (std::min)(static_cast(kMaximumRank), earned + 1)); +} + /** * Sets one global unlock value override, replacing any existing entry for the slot. * @return False when the slot is new and the override list is full. @@ -244,6 +254,86 @@ bool publish_artifact_character_banks(CharacterArtifactWrite& write) noexcept { return publish_artifact_character_banks(write); } +/** Logs one earned perk whose flags were not granted, and why. */ +void report_perk_refusal(std::size_t index, const char* reason) noexcept { + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=season_xp stage=automatic_perk index=%zu result=skip reason=%s", + index, + reason); +} + +// The walk skips an absent acquisition flag and an absent claim flag on one sentinel. +static_assert(build_data::rewards::kAbsent == build_data::season_pass::kUnavailableFlagIndex); + +/** Earned perks set both flags when their rank is reached or an owed walk runs. */ +bool grant_progress_flags(bool& walked) noexcept { + if (!build_data::season_pass_ready() || !build_data::reward_definitions_ready() + || !build_data::item_definitions_ready()) { + investment::store::g_session.perkWalkOwed = true; + return true; + } + AccountState account; + if (!investment::store::read_account(account)) { + return false; + } + const auto character = runtime::detail::selected_character_index(account); + if (character >= account.characterCount) { + return true; + } + unlocks::Table flags{}; + if (!investment::store::read_unlocks(flags, static_cast(character))) { + return false; + } + const auto rank = seasonal_rank(); + const auto count = build_data::season_pass_reward_count(); + for (std::size_t index = 0; index < count; ++index) { + build_data::season_pass::Reward reward{}; + if (!build_data::find_season_pass_reward(static_cast(index), reward)) { + continue; + } + if (reward.requiredRank > rank) { + continue; + } + const auto acquired = pass::progress_flag(reward); + if (acquired == build_data::rewards::kAbsent) { + continue; + } + const auto claim = reward.claimFlagIndex; + if (acquired >= flags.accountFlags.size() || reward.quantity != 1 || reward.socketCount != 0 + || reward.conditionCount > reward.condition.size() + || (claim != build_data::season_pass::kUnavailableFlagIndex + && claim >= flags.accountFlags.size())) { + report_perk_refusal(index, "perk_shape"); + continue; + } + const char* reason = nullptr; + bool eligible = false; + if (!reward_resolution::eligible( + std::span(reward.condition).first(reward.conditionCount), + {flags, account.characters[character].characterClass, 0, &reason}, + eligible)) { + report_perk_refusal(index, reason != nullptr ? reason : "perk_condition"); + continue; + } + if (!eligible) { + continue; + } + for (const auto flag : {acquired, claim}) { + if (flag == build_data::rewards::kAbsent + || flags.accountFlags[flag] == unlocks::kFlagSet) { + continue; + } + if (!unlocks::set_account_flag(flag, unlocks::kFlagSet)) { + return false; + } + flags.accountFlags[flag] = unlocks::kFlagSet; + } + } + walked = true; + return true; +} + /** Publishes the four seasonal progression lanes the current XP total implies. */ bool publish_experience_lanes(std::int32_t experience) noexcept { return unlocks::set_account_progression(kArtifactPowerProgressionIndex, experience) @@ -258,6 +348,22 @@ bool publish_experience_lanes(std::int32_t experience) noexcept { } // namespace +/** Packages share the perk bucket; only rewards without a pool grant the flag directly. */ +std::uint16_t +progression::season_pass::progress_flag(const build_data::season_pass::Reward& reward) noexcept { + build_data::items::Definition item{}; + build_data::rewards::Item acquisition{}; + if (build_data::find_item_definition_index(reward.itemIndex, item) + && item.definitionHash == reward.itemHash + && item.bucketId == build_data::inventory::buckets::kPerkBucketId + && build_data::find_reward_item(reward.itemIndex, acquisition) + && acquisition.definitionHash == reward.itemHash + && acquisition.poolIndex == build_data::rewards::kAbsent) { + return acquisition.acquiredFlag; + } + return build_data::rewards::kAbsent; +} + /** @return Seasonal XP published in the account progression bank. */ std::int32_t seasonal_experience() noexcept { return unlocks::account_progression(kArtifactPowerProgressionIndex); @@ -301,18 +407,23 @@ bool seed_seasonal_progression() noexcept { } } const std::uint32_t mask = artifact_mask(rows, count); - const bool published = publish_experience_lanes(experience) - && publish_artifact_locked(family, mask, experience) - && investment::store::write_family5(family) && transaction.commit(); + bool walked = false; + // A later character pick publishes only a selection patch, so only an owed walk runs here. + const bool published = + publish_experience_lanes(experience) && publish_artifact_locked(family, mask, experience) + && investment::store::write_family5(family) + && (!investment::store::g_session.perkWalkOwed || grant_progress_flags(walked)) + && transaction.commit(); + if (published && walked) { + investment::store::g_session.perkWalkOwed = false; + } investment::store::g_mutex.unlock(); return published; } /** @return One-based Season of Arrivals rank the published XP earns. */ std::uint16_t seasonal_rank() noexcept { - const std::int32_t earned = seasonal_experience() / kExperiencePerRank; - return static_cast( - (std::min)(static_cast(kMaximumRank), earned + 1)); + return seasonal_rank_for(seasonal_experience()); } /** @return Account-wide Power bonus published by the seasonal artifact. */ @@ -331,7 +442,7 @@ std::uint16_t artifact_power_bonus() noexcept { return bonus > 0 ? static_cast(bonus) : 0U; } -/** Adds base XP to the seasonal lanes and republishes every value derived from the total. */ +/** Publishes seasonal XP, artifact progress and newly earned acquisition flags atomically. */ bool grant_seasonal_experience(std::int32_t amount) noexcept { if (amount <= 0) { return false; @@ -344,19 +455,26 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept { if (previous > (std::numeric_limits::max)() - amount) { return false; } - const std::int32_t total = previous + amount; + const auto total = previous + amount; + const bool walkPerks = investment::store::g_session.perkWalkOwed + || seasonal_rank_for(previous) != seasonal_rank_for(total); Family5State family; - if (!transaction.ready() || !investment::store::read_family5(family)) { + if (!investment::store::read_family5(family)) { return false; } SaleRows rows{}; std::size_t count = 0; const std::uint32_t mask = sale_rows(rows, count) ? artifact_mask(rows, count) : 0U; - const bool saved = publish_experience_lanes(total) - && publish_artifact_locked(family, mask, total) - && investment::store::write_family5(family) && transaction.commit(); - return saved; + bool walked = false; + const bool granted = publish_experience_lanes(total) + && publish_artifact_locked(family, mask, total) + && investment::store::write_family5(family) + && (!walkPerks || grant_progress_flags(walked)) && transaction.commit(); + if (granted && walked) { + investment::store::g_session.perkWalkOwed = false; + } + return granted; } /** @return True when this Season pass reward row is already claimed. */ diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp new file mode 100644 index 000000000..7f93cd14e --- /dev/null +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -0,0 +1,857 @@ +#include "state_vendor_reputation_runtime.h" + +#include +#include +#include + +#include "../../middleware/crypto/random_bytes.h" +#include "../build_data/progressions/progression_catalog.h" +#include "../build_data/vendors/reconstructed_rank_claim_links.h" +#include "../build_data/vendors/reputation_sale_catalog.h" +#include "../build_data/vendors/vendor_catalog.h" +#include "../build_data/vendors/vendor_gate_catalog.h" +#include "../investment/store_internal.h" +#include "state_account_transaction_helpers.h" + +namespace sunrise::state { +namespace { + +/** Native progression level walks read experience from lane zero. */ +constexpr std::size_t kExperienceLane = 0; + +/** Saved unlocks and Family-5 overrides visible to one native vendor gate. */ +struct GateValues { + const build_data::vendors::Gate& gate; + const Family5State& family; + const unlocks::Table& banks; +}; + +/** @return The native slot binding, or null when extraction did not resolve it. */ +const build_data::vendors::GateInput* gate_input(const GateValues& values, + build_data::vendors::Opcode opcode, + std::uint16_t slot) noexcept { + const auto inputs = std::span{values.gate.inputs}.first(values.gate.inputCount); + const auto found = std::find_if(inputs.begin(), inputs.end(), [&](const auto& input) { + return input.opcode == opcode && input.slot == slot; + }); + return found == inputs.end() ? nullptr : &*found; +} + +/** + * Reads a native flag slot, preferring a Family-5 override. + * @param context Gate values shared by this evaluation. + * @param instruction Bound native flag slot. + * @param set Receives whether the logical byte is set. + * @return False when the slot has no supported source. + */ +bool gate_flag(const void* context, const unlocks::Instruction& instruction, bool& set) noexcept { + const auto& values = *static_cast(context); + if (instruction.operand > (std::numeric_limits::max)()) { + return false; + } + const auto slot = static_cast(instruction.operand); + const auto* input = gate_input(values, build_data::vendors::Opcode::flag, slot); + if (input == nullptr) { + return false; + } + std::uint8_t logical = 0; + for (std::size_t index = 0; index < values.family.flagCount; ++index) { + if (values.family.flags[index].slot == slot) { + logical = values.family.flags[index].value; + set = logical == unlocks::kFlagSet; + return true; + } + } + using Bank = build_data::vendors::GateBank; + switch (input->bank) { + case Bank::accountFlag: + if (input->row < values.banks.accountFlags.size()) { + logical = values.banks.accountFlags[input->row]; + set = logical == unlocks::kFlagSet; + return true; + } + break; + case Bank::profileFlag: + if (input->row < values.banks.profileFlags.size()) { + logical = values.banks.profileFlags[input->row]; + set = logical == unlocks::kFlagSet; + return true; + } + break; + case Bank::characterFlag: + if (input->row < values.banks.characterObjectFlags.size()) { + logical = values.banks.characterObjectFlags[input->row]; + set = logical == unlocks::kFlagSet; + return true; + } + break; + default: + break; + } + return false; +} + +/** + * Reads a native value slot, preferring a Family-5 override. + * @param context Gate values shared by this evaluation. + * @param instruction Bound native value slot. + * @param value Receives the saved value on success. + * @return False when the slot has no supported source. + */ +bool gate_value(const void* context, + const unlocks::Instruction& instruction, + std::int32_t& value) noexcept { + const auto& values = *static_cast(context); + if (instruction.operand > (std::numeric_limits::max)()) { + return false; + } + const auto slot = static_cast(instruction.operand); + const auto* input = gate_input(values, build_data::vendors::Opcode::loadValue, slot); + if (input == nullptr) { + return false; + } + for (std::size_t index = 0; index < values.family.valueCount; ++index) { + if (values.family.values[index].slot == slot) { + value = values.family.values[index].value; + return true; + } + } + using Bank = build_data::vendors::GateBank; + switch (input->bank) { + case Bank::accountValue: + if (input->row < values.banks.objectiveValues.size()) { + value = values.banks.objectiveValues[input->row]; + return true; + } + break; + case Bank::characterValue: + if (input->row < values.banks.characterObjectValues.size()) { + value = values.banks.characterObjectValues[input->row]; + return true; + } + break; + default: + break; + } + return false; +} + +/** + * Evaluates one authored gate against supplied saved values. + * @param gate Installed gate to evaluate. + * @param family Family-5 overrides. + * @param banks Saved unlock values. + * @return True for an empty gate or a complete expression that evaluates true. + */ +bool gate_passes(const build_data::vendors::Gate& gate, + const Family5State& family, + const unlocks::Table& banks) noexcept { + if (gate.program.count == 0) { + return true; + } + GateValues values{gate, family, banks}; + const unlocks::Inputs inputs{gate_flag, gate_value, &values}; + bool result = false; + const auto program = std::span{gate.program.instructions}.first(gate.program.count); + return std::all_of(program.begin(), program.end(), unlocks::valid) + && unlocks::evaluate(program, inputs, result) && result; +} + +enum class CreditGateKind { unrelated, claim, warning, malformed }; + +/** Instructions consumed by the direct and inverted positive-credit prefixes. */ +constexpr std::size_t kDirectCreditPrefixSize = 3; +constexpr std::size_t kInvertedCreditPrefixSize = 4; + +/** + * Resolves a supported positive-credit prefix without treating a level warning as a claim. + * @param interaction Installed interaction gate. + * @param row Receives the saved character credit row. + * @return The supported claim shape, a warning branch, an unrelated gate or malformed input. + */ +CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interaction, + std::uint16_t& row) noexcept { + namespace vendors = build_data::vendors; + row = 0; + const std::span program = + std::span{interaction.condition.program.instructions}.first( + interaction.condition.program.count); + if (program.empty() || program[0].opcode != vendors::Opcode::loadValue) { + return CreditGateKind::unrelated; + } + std::size_t prefixLength = 0; + if (program.size() >= kDirectCreditPrefixSize && program[1].opcode == vendors::Opcode::constant + && program[1].operand == 0 && program[2].opcode == vendors::Opcode::greaterThan) { + prefixLength = kDirectCreditPrefixSize; + } else if (program.size() >= kInvertedCreditPrefixSize + && program[1].opcode == vendors::Opcode::constant && program[1].operand == 1 + && program[2].opcode == vendors::Opcode::lessThan + && program[3].opcode == vendors::Opcode::logicalNot) { + prefixLength = kInvertedCreditPrefixSize; + } else if (program.size() >= 2 && program[1].opcode == vendors::Opcode::constant + && (program[1].operand == 0 || program[1].operand == 1)) { + return CreditGateKind::malformed; + } else { + return CreditGateKind::unrelated; + } + + if (program[0].operand > (std::numeric_limits::max)()) { + return CreditGateKind::malformed; + } + const std::uint16_t slot = static_cast(program[0].operand); + const std::span inputs = + std::span{interaction.condition.inputs}.first(interaction.condition.inputCount); + const vendors::GateInput* found = nullptr; + for (const vendors::GateInput& input : inputs) { + if (input.opcode != vendors::Opcode::loadValue || input.slot != slot) { + continue; + } + if (found != nullptr || input.bank != vendors::GateBank::characterValue) { + return CreditGateKind::malformed; + } + found = &input; + } + if (found == nullptr || found->row == 0 + || found->row >= unlocks::kCharacterObjectValueCapacity) { + return CreditGateKind::malformed; + } + row = found->row; + const std::span remainder = program.subspan(prefixLength); + if (std::any_of( + remainder.begin(), remainder.end(), [](const unlocks::Instruction& instruction) { + return instruction.opcode == vendors::Opcode::lessThan + || instruction.opcode == vendors::Opcode::lessOrEqual; + })) { + return CreditGateKind::warning; + } + return CreditGateKind::claim; +} + +/** + * Resolves the exact reconstructed interaction's installed credit row. + * @param link Reconstructed interaction link. + * @param interaction Receives the installed interaction gate. + * @param row Receives the saved character credit row. + * @return False when the exact link is missing, changed or not a claim gate. + */ +bool reconstructed_credit_gate(const build_data::vendors::ReconstructedRankClaimLink& link, + build_data::vendors::InteractionGate& interaction, + std::uint16_t& row) noexcept { + namespace vendors = build_data::vendors; + interaction = {}; + row = 0; + return vendors::find_interaction_gate(link.vendorHash, link.interactionIndex, interaction) + && interaction.categoryIndex == link.categoryIndex + && credit_gate(interaction, row) == CreditGateKind::claim; +} + +/** Installed sale, reward and gate identities captured for commit revalidation. */ +struct RewardSaleBinding { + build_data::vendors::InteractionGate interaction{}; + build_data::vendors::SaleGates gates{}; + std::uint32_t packageHash{}; + std::int32_t categoryIndex{build_data::vendors::kAbsentCategoryIndex}; + std::uint16_t itemIndex{}; + std::uint16_t poolIndex{build_data::rewards::kAbsent}; + std::uint16_t rankCreditRow{}; +}; + +/** + * Confirms that the installed faction participates in the authored reputation catalog. + * @param vendor Installed vendor definition. + * @return True when at least one exact turn-in names this vendor and faction. + */ +bool has_reputation_context(const build_data::vendors::Definition& vendor) noexcept { + namespace vendors = build_data::vendors; + if (vendor.factionHash == 0 + || vendor.factionProgressionIndex == vendors::kUnavailableFactionProgressionIndex) { + return false; + } + for (std::size_t index = 0; index < vendor.saleCount; ++index) { + vendors::ReputationSale sale{}; + if (vendors::find_reputation_sale( + vendor.definitionHash, static_cast(index), sale) + && sale.factionHash == vendor.factionHash) { + return true; + } + } + return false; +} + +/** + * Finds one non-warning credit claim in the requested installed category. + * @param vendor Installed vendor definition. + * @param categoryIndex Requested sale category. + * @param interaction Receives the unique claim interaction. + * @param row Receives its saved character credit row. + * @return False for zero, duplicate or malformed claim candidates. + */ +bool find_rank_claim_interaction(const build_data::vendors::Definition& vendor, + std::int32_t categoryIndex, + build_data::vendors::InteractionGate& interaction, + std::uint16_t& row) noexcept { + namespace vendors = build_data::vendors; + interaction = {}; + row = 0; + bool found = false; + for (std::size_t index = 0; index < vendor.thirdCount; ++index) { + vendors::InteractionGate candidate{}; + if (!vendors::find_interaction_gate( + vendor.definitionHash, static_cast(index), candidate) + || candidate.categoryIndex != categoryIndex) { + continue; + } + std::uint16_t candidateRow = 0; + switch (credit_gate(candidate, candidateRow)) { + case CreditGateKind::unrelated: + case CreditGateKind::warning: + continue; + case CreditGateKind::malformed: + return false; + case CreditGateKind::claim: + if (found) { + return false; + } + found = true; + interaction = candidate; + row = candidateRow; + break; + } + } + return found; +} + +/** + * Resolves one exact sale-backed rank wrapper from installed content. + * @param vendorIndex Installed vendor selector. + * @param saleIndex Requested sale selector. + * @param binding Receives current package, pool and gate identities. + * @return Unrelated ordinary sale, refused candidate or prepared rank wrapper. + */ +VendorReputationDisposition resolve_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + RewardSaleBinding& binding) noexcept { + namespace vendors = build_data::vendors; + binding = {}; + vendors::IndexEntry entry{}; + vendors::Definition vendor{}; + vendors::SaleRow sale{}; + if (!vendors::find_index(vendorIndex, entry) || !vendors::find(entry.definitionHash, vendor)) { + return VendorReputationDisposition::notApplicable; + } + if (!vendors::sale_row(vendor, saleIndex, sale)) { + return VendorReputationDisposition::refused; + } + vendors::SaleGates gates{}; + const bool retained = vendors::find_sale_gates(entry.definitionHash, saleIndex, gates); + const bool freeRewardCandidate = sale.categoryIndex != vendors::kAbsentCategoryIndex + && sale.priceState == vendors::PriceState::plain + && sale.costCount == 0; + if (!freeRewardCandidate) { + return retained ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + build_data::items::Definition package{}; + build_data::rewards::Item reward{}; + const bool context = has_reputation_context(vendor); + const bool rewardCatalog = build_data::reward_definitions_ready(); + const bool itemFound = build_data::find_item_definition_index(sale.itemIndex, package); + const bool rewardFound = rewardCatalog && build_data::find_reward_item(sale.itemIndex, reward); + if (!rewardCatalog || !itemFound) { + return retained || context ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + if (!rewardFound) { + return retained ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + if (package.definitionHash != reward.definitionHash) { + return retained || context ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + if (reward.poolIndex == build_data::rewards::kAbsent + || (reward.flags & build_data::rewards::kOpenOnAcquisition) == 0) { + return retained ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + if (!context || !retained || gates.categoryIndex != sale.categoryIndex) { + return VendorReputationDisposition::refused; + } + build_data::vendors::InteractionGate interaction{}; + std::uint16_t creditRow = 0; + if (!find_rank_claim_interaction(vendor, sale.categoryIndex, interaction, creditRow)) { + return VendorReputationDisposition::refused; + } + binding.interaction = interaction; + binding.gates = gates; + binding.packageHash = package.definitionHash; + binding.categoryIndex = sale.categoryIndex; + binding.itemIndex = sale.itemIndex; + binding.poolIndex = reward.poolIndex; + binding.rankCreditRow = creditRow; + return VendorReputationDisposition::prepared; +} + +/** + * Evaluates the claim interaction and exact sale gates against current saved state. + * @param binding Resolved installed sale authority. + * @param replyIndex Rowless reply selector, absent for a sale-backed claim. + * @return False when any saved input is missing or any complete gate refuses the claim. + */ +bool reward_gates_pass(const RewardSaleBinding& binding, + std::optional replyIndex) noexcept { + // The reconstructed rowless links cover only single-reply interactions. + const auto& replies = binding.interaction.replyConditions; + if (replyIndex && (replies.size() != 1 || *replyIndex >= replies.size())) { + return false; + } + Family5State family{}; + AccountState account{}; + unlocks::Table banks{}; + if (!investment::store::read_family5(family) || !investment::store::read_account(account) + || !account::valid(account)) { + return false; + } + const auto selected = runtime::detail::selected_character_index(account); + return selected < account.characterCount + && investment::store::read_unlocks(banks, static_cast(selected)) + && gate_passes(binding.interaction.condition, family, banks) + && (!replyIndex || gate_passes(replies[*replyIndex], family, banks)) + && gate_passes(binding.gates.admission, family, banks) + && gate_passes(binding.gates.selection, family, banks); +} + +/** @return True when a prepared claim still names the same installed sale authority. */ +bool same_reward_binding(const VendorRankRewardClaim& claim, + const RewardSaleBinding& binding) noexcept { + return claim.packageHash == binding.packageHash && claim.categoryIndex == binding.categoryIndex + && claim.itemIndex == binding.itemIndex && claim.poolIndex == binding.poolIndex + && claim.interactionIndex == binding.interaction.index + && claim.rankCreditRow == binding.rankCreditRow; +} + +/** + * Resolves a turn-in without treating another purchase using the same material as reputation. + * @param vendorIndex Installed vendor selector. + * @param saleIndex Installed sale selector. + * @param award Receives the checked cost and XP; use only when prepared is returned. + * @return Not applicable for other sales, refused for unsupported costs on a known placeholder. + */ +VendorReputationDisposition resolve_reputation_turn_in(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + VendorReputationAward& award) noexcept { + namespace vendors = build_data::vendors; + vendors::IndexEntry entry{}; + vendors::Definition vendor{}; + vendors::SaleRow sale{}; + build_data::items::Definition sold{}, cost{}; + award = {}; + if (!vendors::find_index(vendorIndex, entry) || !vendors::find(entry.definitionHash, vendor) + || !vendors::sale_row(vendor, saleIndex, sale) + || !build_data::find_item_definition_index(sale.itemIndex, sold)) { + return VendorReputationDisposition::notApplicable; + } + vendors::ReputationSale authored{}; + if (!vendors::find_reputation_sale(entry.definitionHash, saleIndex, authored)) { + return vendors::is_reputation_placeholder(entry.definitionHash, sold.definitionHash) + ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + const auto price = vendors::cost_entries(sale); + // The manifest's token value is trusted only for this exact installed sale and faction. + if (sold.definitionHash != authored.placeholderHash + || sale.categoryIndex != authored.categoryIndex + || sale.priceState != vendors::PriceState::plain || price.size() != 1) { + return VendorReputationDisposition::refused; + } + const auto& saleCost = price.front(); + if (saleCost.itemIndex == vendors::kAbsentCostItem || saleCost.quantity != authored.costQuantity + || !build_data::find_item_definition_index(saleCost.itemIndex, cost) + || cost.definitionHash != authored.costHash || vendor.factionHash != authored.factionHash) { + return VendorReputationDisposition::refused; + } + std::array slots{}; + std::size_t count = 0; + build_data::progressions::Definition progression{}; + const auto progressionIndex = vendor.factionProgressionIndex; + const std::int64_t experience = + static_cast(saleCost.quantity) * authored.experiencePerUnit; + if (progressionIndex == vendors::kUnavailableFactionProgressionIndex + || experience > (std::numeric_limits::max)() + || !build_data::find_progression_slots( + build_data::progressions::Scope::character, slots, count) + || count > slots.size() + || std::find( + slots.begin(), slots.begin() + static_cast(count), progressionIndex) + == slots.begin() + static_cast(count) + || !build_data::progressions::find(progressionIndex, progression) + || progression.scope != build_data::progressions::Scope::character) { + return VendorReputationDisposition::refused; + } + award.costHash = cost.definitionHash; + award.costQuantity = saleCost.quantity; + award.experience = static_cast(experience); + award.progressionIndex = progressionIndex; + const auto* link = vendors::find_reconstructed_rank_claim_link(entry.definitionHash); + if (link != nullptr) { + vendors::InteractionGate interaction{}; + if (!reconstructed_credit_gate(*link, interaction, award.rankCreditRow)) { + return VendorReputationDisposition::refused; + } + } + award.repeatLastStep = progression.repeatLastStep; + if (award.rankCreditRow != 0) { + std::array + steps{}; + if (!build_data::progressions::steps(progressionIndex, steps, count) || count < 2 + || std::any_of( + steps.begin(), + steps.begin() + static_cast(count), + [](const auto& step) { return step.cost <= 0; })) { + return VendorReputationDisposition::refused; + } + award.rankStepCount = count; + std::transform(steps.begin(), + steps.begin() + static_cast(count), + award.rankStepCosts.begin(), + [](const auto& step) { return step.cost; }); + } + return VendorReputationDisposition::prepared; +} + +/** + * Charges profile stacks in their existing order without announcing an item acquisition. + * @param account Candidate account; discard it on failure. + * @param award Validated sale payment. + * @return False when materials are insufficient or the resulting profile is invalid. + */ +bool charge_materials(AccountState& account, const VendorReputationAward& award) noexcept { + std::uint32_t remaining = award.costQuantity; + std::size_t write = 0; + for (std::size_t read = 0; read < account.profileItemCount; ++read) { + auto item = account.profileItems[read]; + if (item.definitionHash == award.costHash) { + if (item.instanceSoid != 0 || item.quantity <= 0) { + return false; + } + const auto charged = (std::min)(remaining, static_cast(item.quantity)); + item.quantity -= static_cast(charged); + remaining -= charged; + } + if (item.quantity != 0) { + account.profileItems[write++] = item; + } + } + std::fill(account.profileItems.begin() + static_cast(write), + account.profileItems.end(), + account::inventory::ProfileItem{}); + account.profileItemCount = write; + return remaining == 0 && account::valid(account) + && runtime::detail::valid_profile_inventory(account); +} + +/** + * Walks one captured ladder and repeats its final cost only when installed content says to. + * @param award Prepared award carrying the installed rank costs. + * @param experience Nonnegative progression XP. + * @param rank Receives the number of completed positive-cost steps. + * @return False when the captured ladder cannot define ranks. + */ +bool rank_at_experience(const VendorReputationAward& award, + std::int64_t experience, + std::int64_t& rank) noexcept { + rank = 0; + if (experience < 0 || award.rankStepCount < 2 + || award.rankStepCount > award.rankStepCosts.size() || award.rankStepCosts[0] <= 0) { + return false; + } + std::int64_t remaining = experience; + for (std::size_t step = 0; step < award.rankStepCount; ++step) { + const auto cost = award.rankStepCosts[step]; + if (cost <= 0) { + return false; + } + if (remaining < cost) { + return true; + } + remaining -= cost; + ++rank; + } + if (award.repeatLastStep) { + rank += remaining / award.rankStepCosts[award.rankStepCount - 1]; + } + return true; +} + +/** + * Reconstructed policy awards one claim credit per newly crossed rank, without backfilling XP. + * @param banks Candidate unlock banks; no saved state is written here. + * @param award Build-matched turn-in and captured rank ladder. + * @param beforeExperience Nonnegative XP before this turn-in. + * @return False when the ladder or counter is invalid or the complete credit would overflow. + */ +bool grant_rank_rewards(unlocks::Table& banks, + const VendorReputationAward& award, + std::int32_t beforeExperience) noexcept { + if (award.rankStepCount == 0) { + return true; + } + if (beforeExperience < 0 || award.experience <= 0) { + return false; + } + const auto afterExperience = static_cast(beforeExperience) + award.experience; + std::int64_t beforeRank = 0, afterRank = 0; + if (!rank_at_experience(award, beforeExperience, beforeRank) + || !rank_at_experience(award, afterExperience, afterRank) || afterRank < beforeRank) { + return false; + } + const auto count = afterRank - beforeRank; + auto& credits = banks.characterObjectValues[award.rankCreditRow]; + if (credits < 0 || count > (std::numeric_limits::max)() - credits) { + return false; + } + credits += static_cast(count); + return true; +} + +/** + * Prepares one shared package grant after validating its sale, credit and optional reply. + * @param vendorIndex Installed vendor selector. + * @param saleIndex Requested or reconstructed package sale. + * @param replyIndex Rowless reply selector, absent for a sale-backed claim. + * @param mutation Receives the prepared grant; cleared on refusal. + * @param reason Receives the first failed guard, or null on success. + * @return Unrelated sale, refused candidate or prepared rank reward. + */ +VendorReputationDisposition prepare_rank_reward(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + std::optional replyIndex, + PendingRecordRewardGrant& mutation, + const char*& reason) noexcept { + reason = "reward_binding"; + mutation = {}; + const std::lock_guard lock(investment::store::g_mutex); + RewardSaleBinding binding{}; + const auto disposition = resolve_reward_sale(vendorIndex, saleIndex, binding); + if (disposition != VendorReputationDisposition::prepared) { + return disposition; + } + reason = "rank_credit"; + std::int32_t credits = 0; + if (!investment::store::read_unlock( + investment::store::Bank::characterObjectValues, binding.rankCreditRow, credits) + || credits <= 0) { + return VendorReputationDisposition::refused; + } + reason = "reward_binding"; + if (!reward_gates_pass(binding, replyIndex)) { + return VendorReputationDisposition::refused; + } + std::uint64_t seed = 0; + reason = "random_source"; + if (!middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)))) { + return VendorReputationDisposition::refused; + } + if (prepare_item_reward(binding.itemIndex, 1, seed, mutation, &reason) + != RewardPreparation::prepared) { + mutation = {}; + return VendorReputationDisposition::refused; + } + reason = nullptr; + mutation.vendorReward.beforeCredits = credits; + mutation.vendorReward.vendorIndex = vendorIndex; + mutation.vendorReward.saleIndex = saleIndex; + mutation.vendorReward.packageHash = binding.packageHash; + mutation.vendorReward.categoryIndex = binding.categoryIndex; + mutation.vendorReward.itemIndex = binding.itemIndex; + mutation.vendorReward.poolIndex = binding.poolIndex; + mutation.vendorReward.interactionIndex = binding.interaction.index; + mutation.vendorReward.replyIndex = replyIndex; + mutation.vendorReward.rankCreditRow = binding.rankCreditRow; + return VendorReputationDisposition::prepared; +} + +} // namespace + +/** + * Captures an affordable turn-in without writing inventory or progression. + * @param vendorIndex Installed vendor selector. + * @param saleIndex Installed sale selector. + * @param mutation Receives the before-image; cleared on failure. + * @return Whether this sale is a prepared, refused or unrelated reputation action. + */ +VendorReputationDisposition prepare_vendor_reputation(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + PendingVendorReputation& mutation) noexcept { + mutation = {}; + VendorReputationAward award{}; + const auto disposition = resolve_reputation_turn_in(vendorIndex, saleIndex, award); + if (disposition != VendorReputationDisposition::prepared) { + return disposition; + } + const std::lock_guard lock(investment::store::g_mutex); + AccountState account{}; + unlocks::Table banks{}; + if (!investment::store::read_account(account) || !account::valid(account) + || !runtime::detail::valid_profile_inventory(account)) { + return VendorReputationDisposition::refused; + } + const auto selected = runtime::detail::selected_character_index(account); + if (selected >= account.characterCount || account.primarySoid == 0 + || !investment::store::read_unlocks(banks, static_cast(selected))) { + return VendorReputationDisposition::refused; + } + const auto before = banks.characterProgressions[award.progressionIndex]; + if (before[kExperienceLane] < 0 + || before[kExperienceLane] + > (std::numeric_limits::max)() - award.experience) { + return VendorReputationDisposition::refused; + } + AccountState after = account; + const auto beforeCredits = banks.characterObjectValues[award.rankCreditRow]; + if (!charge_materials(after, award) + || !grant_rank_rewards(banks, award, before[kExperienceLane])) { + return VendorReputationDisposition::refused; + } + mutation.beforeItems = account.profileItems; + mutation.beforeItemCount = account.profileItemCount; + mutation.beforeProgression = before; + mutation.beforeRankCredits = beforeCredits; + mutation.award = award; + mutation.accountSoid = account.primarySoid; + mutation.characterSoid = account.characters[selected].soid; + mutation.characterIndex = selected; + mutation.vendorIndex = vendorIndex; + mutation.saleIndex = saleIndex; + mutation.prepared = true; + return VendorReputationDisposition::prepared; +} + +/** + * Materials, XP and claim credits share the caller's response transaction. + * @param mutation Before-image to consume, including on refusal. + * @return False for stale state, changed content or any failed grant/write; all changes roll back. + */ +bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { + const runtime::detail::PendingConsumption consume(mutation); + VendorReputationAward award{}; + if (!mutation.prepared + || resolve_reputation_turn_in(mutation.vendorIndex, mutation.saleIndex, award) + != VendorReputationDisposition::prepared + || award != mutation.award) { + return false; + } + investment::store::Transaction transaction; + AccountState account{}; + unlocks::Table banks{}; + if (!transaction.ready() || !investment::store::read_account(account) + || !account::valid(account) || account.primarySoid != mutation.accountSoid + || !runtime::detail::valid_profile_inventory(account) + || mutation.characterIndex >= account.characterCount + || account.characters[mutation.characterIndex].soid != mutation.characterSoid + || !account.characters[mutation.characterIndex].selected + || !runtime::detail::same_profile_inventory( + account, mutation.beforeItems, mutation.beforeItemCount) + || !investment::store::read_unlocks(banks, static_cast(mutation.characterIndex))) { + return false; + } + auto& progression = banks.characterProgressions[award.progressionIndex]; + if (progression != mutation.beforeProgression || progression[kExperienceLane] < 0 + || (award.rankStepCount != 0 + && banks.characterObjectValues[award.rankCreditRow] != mutation.beforeRankCredits) + || progression[kExperienceLane] + > (std::numeric_limits::max)() - award.experience + || !charge_materials(account, award) + || !grant_rank_rewards(banks, award, progression[kExperienceLane])) { + return false; + } + progression[kExperienceLane] += award.experience; + return investment::store::write_account(account) + && investment::store::write_unlocks(banks, static_cast(mutation.characterIndex)) + && transaction.commit(); +} + +/** + * Resolves a supported rowless reply through the same settlement as its package sale. + * @param vendorIndex Installed vendor selector. + * @param interactionIndex Rowless interaction selector. + * @param replyIndex Reply within that interaction. + * @param mutation Receives the prepared grant; cleared on refusal. + * @return Recognized but unsupported replies refuse rather than falling through to a free grant. + */ +VendorReputationDisposition +prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, + std::uint16_t interactionIndex, + std::uint16_t replyIndex, + PendingRecordRewardGrant& mutation) noexcept { + mutation = {}; + build_data::vendors::IndexEntry entry{}; + if (!build_data::vendors::find_index(vendorIndex, entry)) { + return VendorReputationDisposition::notApplicable; + } + const auto* link = + build_data::vendors::find_reconstructed_rank_claim_link(entry.definitionHash); + if (link == nullptr || link->interactionIndex != interactionIndex) { + build_data::vendors::InteractionGate interaction{}; + std::uint16_t row = 0; + if (build_data::vendors::find_interaction_gate( + entry.definitionHash, interactionIndex, interaction) + && credit_gate(interaction, row) != CreditGateKind::unrelated) { + return VendorReputationDisposition::refused; + } + return VendorReputationDisposition::notApplicable; + } + const char* reason = nullptr; + const auto disposition = + prepare_rank_reward(vendorIndex, link->saleIndex, replyIndex, mutation, reason); + if (disposition != VendorReputationDisposition::prepared + || mutation.vendorReward.interactionIndex != link->interactionIndex + || mutation.vendorReward.categoryIndex != link->categoryIndex) { + mutation = {}; + return VendorReputationDisposition::refused; + } + return VendorReputationDisposition::prepared; +} + +/** + * A reward sale binds its rank credit to the installed package's prepared grant. + * @param vendorIndex Installed vendor selector. + * @param saleIndex Requested package sale. + * @param mutation Receives the prepared grant; cleared on refusal. + * @param refusal Receives the first failed guard, when requested. + * @return Unrelated sales are not applicable; unsupported or unaffordable rewards are refused. + */ +VendorReputationDisposition prepare_vendor_rank_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + const char* unused = nullptr; + auto& reason = refusal != nullptr ? *refusal : unused; + return prepare_rank_reward(vendorIndex, saleIndex, std::nullopt, mutation, reason); +} + +/** + * Caller holds the investment-store lock across reward preview and this pre-publication recheck. + * @param claim Prepared claim state; zero credits mean no vendor claim. + * @return False for a stale credit or changed package gate. + */ +bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept { + if (claim.beforeCredits == 0) { + return true; + } + std::int32_t current = 0; + RewardSaleBinding binding{}; + if (claim.beforeCredits < 0 + || resolve_reward_sale(claim.vendorIndex, claim.saleIndex, binding) + != VendorReputationDisposition::prepared + || !same_reward_binding(claim, binding) || !reward_gates_pass(binding, claim.replyIndex) + || !investment::store::read_unlock( + investment::store::Bank::characterObjectValues, claim.rankCreditRow, current) + || current != claim.beforeCredits) { + return false; + } + return true; +} + +} // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h new file mode 100644 index 000000000..c5a2ca422 --- /dev/null +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -0,0 +1,80 @@ +#pragma once + +#include + +#include "../account/account_state.h" +#include "../build_data/progressions/definition.h" +#include "../unlocks/definition.h" + +namespace sunrise::state { + +/** A recognized reputation placeholder must never fall through to an item grant. */ +enum class VendorReputationDisposition : std::uint8_t { notApplicable, refused, prepared }; + +/** Installed sale cost and its build-matched faction award. */ +struct VendorReputationAward { + std::uint32_t costHash{}; + std::uint32_t costQuantity{}; + std::int32_t experience{}; + std::uint16_t progressionIndex{}; + /** Saved rank-credit row; used only when rankStepCount is positive. */ + std::uint16_t rankCreditRow{}; + /** Installed rank costs captured for commit revalidation. */ + std::array rankStepCosts{}; + /** Zero preserves XP-only handling for factions without a rank-claim link. */ + std::size_t rankStepCount{}; + /** Build-86657 progression metadata marks these faction ladders as repeating. */ + bool repeatLastStep{}; + bool operator==(const VendorReputationAward&) const = default; +}; + +/** Captured payment and XP; rank rewards commit with the same turn-in. */ +struct PendingVendorReputation { + std::array + beforeItems{}; + unlocks::ProgressionLanes beforeProgression{}; + std::int32_t beforeRankCredits{}; + VendorReputationAward award{}; + std::uint64_t accountSoid{}; + std::uint64_t characterSoid{}; + std::size_t characterIndex{}; + std::size_t beforeItemCount{}; + std::uint16_t vendorIndex{}; + std::uint16_t saleIndex{}; + bool prepared{}; +}; + +[[nodiscard]] VendorReputationDisposition prepare_vendor_reputation( + std::uint16_t vendorIndex, std::uint16_t saleIndex, PendingVendorReputation& mutation) noexcept; +[[nodiscard]] bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept; + +/** Positive credits identify a prepared claim; zero leaves ordinary rewards unchanged. */ +struct VendorRankRewardClaim { + std::int32_t beforeCredits{}; + std::uint16_t vendorIndex{}; + std::uint16_t saleIndex{}; + std::uint32_t packageHash{}; + std::int32_t categoryIndex{}; + std::uint16_t itemIndex{}; + std::uint16_t poolIndex{}; + std::uint16_t interactionIndex{}; + /** Rowless claims revalidate this reply; a sale-backed request has no reply selector. */ + std::optional replyIndex; + /** Saved rank-credit row rechecked before preview or commit. */ + std::uint16_t rankCreditRow{}; +}; + +struct PendingRecordRewardGrant; +[[nodiscard]] VendorReputationDisposition +prepare_vendor_rank_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + PendingRecordRewardGrant& mutation, + const char** refusal = nullptr) noexcept; +[[nodiscard]] VendorReputationDisposition +prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, + std::uint16_t interactionIndex, + std::uint16_t replyIndex, + PendingRecordRewardGrant& mutation) noexcept; +[[nodiscard]] bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept; + +} // namespace sunrise::state diff --git a/Sunrise/src/state/unlocks/unlocks_expression.cpp b/Sunrise/src/state/unlocks/unlocks_expression.cpp new file mode 100644 index 000000000..7ec9a6e0d --- /dev/null +++ b/Sunrise/src/state/unlocks/unlocks_expression.cpp @@ -0,0 +1,203 @@ +#include "unlocks_expression.h" + +#include +#include + +#include "../account/account_state.h" +#include "definition.h" + +namespace sunrise::state::unlocks { +namespace { + +/** Fixed evaluation stack, which refuses rather than growing. */ +class Stack final { +public: + /** @param value Value to push. @return True when there was room. */ + [[nodiscard]] bool push(std::int32_t value) noexcept { + if (depth_ == values_.size()) { + return false; + } + values_[depth_++] = value; + return true; + } + + /** @param value Receives the top value. @return True when there was one. */ + [[nodiscard]] bool pop(std::int32_t& value) noexcept { + if (depth_ == 0) { + return false; + } + value = values_[--depth_]; + return true; + } + + /** @return Values currently held. */ + [[nodiscard]] std::size_t depth() const noexcept { + return depth_; + } + +private: + std::array values_{}; + std::size_t depth_{}; +}; + +/** Signed arithmetic wraps rather than overflowing. */ +[[nodiscard]] std::int32_t wrapping_add(std::int32_t left, std::int32_t right) noexcept { + return static_cast(static_cast(left) + + static_cast(right)); +} + +/** + * Applies one binary operator. + * @return False when the opcode is not a binary operator. + */ +[[nodiscard]] bool +binary_result(Opcode opcode, std::int32_t left, std::int32_t right, std::int32_t& value) noexcept { + switch (opcode) { + case Opcode::logicalOr: + value = left != 0 || right != 0; + return true; + case Opcode::logicalAnd: + value = left != 0 && right != 0; + return true; + case Opcode::equal: + value = left == right; + return true; + case Opcode::greaterThan: + value = left > right; + return true; + case Opcode::greaterOrEqual: + value = left >= right; + return true; + case Opcode::lessThan: + value = left < right; + return true; + case Opcode::lessOrEqual: + value = left <= right; + return true; + case Opcode::add: + value = wrapping_add(left, right); + return true; + default: + return false; + } +} + +/** + * Runs one instruction against the stack. + * @param instruction Instruction to run. + * @param inputs Flag and value readers. + * @param stack Evaluation stack. + * @return True when the opcode is known and its operands were there. + */ +[[nodiscard]] bool +step(const Instruction& instruction, const Inputs& inputs, Stack& stack) noexcept { + std::int32_t left = 0; + std::int32_t right = 0; + std::int32_t value = 0; + switch (instruction.opcode) { + case Opcode::flag: { + bool set = false; + return inputs.flag(inputs.context, instruction, set) && stack.push(set ? 1 : 0); + } + case Opcode::loadValue: + return inputs.value(inputs.context, instruction, value) && stack.push(value); + case Opcode::constant: + return stack.push(static_cast(instruction.operand)); + case Opcode::logicalNot: + return stack.pop(left) && stack.push(left == 0 ? 1 : 0); + case Opcode::negate: + return stack.pop(left) && stack.push(wrapping_add(~left, 1)); + default: + // The right operand was pushed last, so it comes off first. + return stack.pop(right) && stack.pop(left) + && binary_result(instruction.opcode, left, right, value) && stack.push(value); + } +} + +} // namespace + +/** Converts one native opcode word to an evaluated opcode. */ +bool decode_opcode(std::uint32_t native, Opcode& opcode) noexcept { + if (native > (std::numeric_limits::max)()) { + return false; + } + switch (static_cast(native)) { + case Opcode::flag: + case Opcode::logicalNot: + case Opcode::logicalOr: + case Opcode::logicalAnd: + case Opcode::equal: + case Opcode::loadValue: + case Opcode::constant: + case Opcode::greaterThan: + case Opcode::greaterOrEqual: + case Opcode::lessThan: + case Opcode::lessOrEqual: + case Opcode::add: + case Opcode::negate: + opcode = static_cast(native); + return true; + default: + return false; + } +} + +/** Checks one instruction's bank and its operand against that bank's capacity. */ +bool valid(const Instruction& instruction) noexcept { + Opcode decoded{}; + if (!decode_opcode(static_cast(instruction.opcode), decoded)) { + return false; + } + const std::uint32_t index = instruction.operand; + if (instruction.opcode == Opcode::flag) { + switch (instruction.bank) { + case Bank::account: + return index < kAccountFlagCapacity; + case Bank::profile: + return index < kProfileFlagCapacity; + case Bank::character: + return index < kCharacterObjectFlagCapacity; + case Bank::characterClass: + return index < kCharacterClassCount; + case Bank::external: + return true; + default: + return false; + } + } + if (instruction.opcode == Opcode::loadValue) { + switch (instruction.bank) { + case Bank::account: + return index < kObjectiveValueCapacity; + case Bank::character: + return index < kCharacterObjectValueCapacity; + case Bank::external: + return true; + default: + return false; + } + } + return instruction.bank == Bank::none; +} + +/** Evaluates one expression program. */ +bool evaluate(std::span program, const Inputs& inputs, bool& result) noexcept { + result = false; + if (program.empty() || inputs.flag == nullptr || inputs.value == nullptr) { + return false; + } + Stack stack; + for (const Instruction& instruction : program) { + if (!step(instruction, inputs, stack)) { + return false; + } + } + std::int32_t final = 0; + if (stack.depth() != 1 || !stack.pop(final)) { + return false; + } + result = final != 0; + return true; +} + +} // namespace sunrise::state::unlocks diff --git a/Sunrise/src/state/unlocks/unlocks_expression.h b/Sunrise/src/state/unlocks/unlocks_expression.h new file mode 100644 index 000000000..996057815 --- /dev/null +++ b/Sunrise/src/state/unlocks/unlocks_expression.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include + +namespace sunrise::state::unlocks { + +/** Stack slots one expression may use. The deepest installed reward condition needs 20. */ +inline constexpr std::size_t kExpressionStackCapacity = 32; + +/** Opcodes the client's expression evaluator implements that the server evaluates. */ +enum class Opcode : std::uint8_t { + /** Pushes 1 when the bound flag is set, otherwise 0. */ + flag = 1, + /** Pops one value and pushes 1 when it was zero. */ + logicalNot = 2, + /** Binary operators pop right then left and push the result. */ + logicalOr = 3, + logicalAnd = 4, + equal = 8, + /** Pushes one bound value. */ + loadValue = 10, + /** Pushes the operand as a signed constant. */ + constant = 11, + greaterThan = 13, + greaterOrEqual = 14, + lessThan = 15, + lessOrEqual = 16, + add = 17, + /** Pops one value and pushes its two's-complement negation. */ + negate = 22, +}; + +/** Saved bank a flag or value instruction reads once its authored unlock slot is bound. */ +enum class Bank : std::uint8_t { + /** Operators and constants read no bank. */ + none, + account, + profile, + character, + /** A flag that is set when the operand names the selected character's class. */ + characterClass, + /** Resolved by the caller; the operand is its reward identity or native vendor slot. */ + external, +}; + +/** One bound expression instruction. */ +struct Instruction { + Opcode opcode{}; + Bank bank{Bank::none}; + /** Bank index, class index, caller-owned external operand, or signed constant. */ + std::uint32_t operand{}; +}; + +/** Reads one bound flag. Returns false when the flag cannot be read. */ +using FlagReader = bool (*)(const void* context, + const Instruction& instruction, + bool& set) noexcept; +/** Reads one bound value. Returns false when the value cannot be read. */ +using ValueReader = bool (*)(const void* context, + const Instruction& instruction, + std::int32_t& value) noexcept; + +/** The two readers one evaluation needs, and the caller state they share. */ +struct Inputs { + FlagReader flag{}; + ValueReader value{}; + const void* context{}; +}; + +/** + * Converts one native opcode word to an evaluated opcode. + * @return False for an opcode the server does not evaluate. + */ +[[nodiscard]] bool decode_opcode(std::uint32_t native, Opcode& opcode) noexcept; + +/** Checks one instruction's bank and its operand against that bank's capacity. */ +[[nodiscard]] bool valid(const Instruction& instruction) noexcept; + +/** + * Evaluates one expression program. + * Every failure is refused, never defaulted: bad opcode, bad stack, unreadable flag or value. + * @param program Instructions in evaluation order. + * @param inputs Flag and value readers. + * @param result Receives whether the final value is nonzero, and is cleared on any refusal. + * @return True only when the whole program evaluated to one value. + */ +[[nodiscard]] bool +evaluate(std::span program, const Inputs& inputs, bool& result) noexcept; + +} // namespace sunrise::state::unlocks