diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 6cd0414ae..563404e2a 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -99,6 +99,11 @@ + + + + + @@ -642,7 +647,7 @@ - + @@ -1254,6 +1259,11 @@ + + + + + @@ -1510,7 +1520,7 @@ - + diff --git a/Sunrise/src/client/content/items/packages/internal.h b/Sunrise/src/client/content/items/packages/internal.h index 33aff9e42..c2ace82e7 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{}; @@ -154,15 +158,11 @@ struct Storage { state::build_data::progressions::kStepCapacity> progressionSteps{}; std::size_t progressionStepCount{}; - /** Season pass reward rows and the wrapper items they grant. */ + /** Season pass reward rows in native claim order. */ std::array seasonPassRewards{}; - std::array - seasonPassPackages{}; std::size_t seasonPassRewardCount{}; - std::size_t seasonPassPackageCount{}; /** Repeatable bounty rows, keyed by the item-type pair their pool shares. */ std::array @@ -207,6 +207,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; @@ -324,7 +327,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. */ @@ -379,7 +382,7 @@ read_investment_constants(const reader::Source& source, std::span steps, std::size_t& stepCount) noexcept; -/** Reads the season pass reward list and the wrapper items it grants. */ +/** Reads the season pass reward list, keeping an unreadable row as an unavailable one. */ [[nodiscard]] bool build_season_pass(const reader::Source& source, Storage& storage, std::span root) noexcept; 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..0fa0b810d 100644 --- a/Sunrise/src/client/content/items/packages/package_item_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_build.cpp @@ -17,6 +17,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 +35,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() @@ -126,7 +127,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()) { reason = "unlock_maps"; if (!read_unlock_slot_maps( source, storage, std::span{storage.root})) { @@ -184,11 +186,18 @@ 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( - std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount), - std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount)); + // 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))) { + 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. 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_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp new file mode 100644 index 000000000..ef9e0a272 --- /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; + +/** Byte offsets inside the serialized reward rows. */ +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..9a689b186 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,73 +1,94 @@ #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. - * @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. + * Reads one pass row: item, rank, quantity and claim flag, then its sockets and conditions. + * @return False when any field is unreadable or out of range. */ -[[nodiscard]] bool read_package(std::span definition, - std::span itemTable, - const tables::Array& itemRows, - domain::Package& package) noexcept { - tables::Array set{}; - if (!tables::find_optional_array_at(definition, tables::kGearsetItemField, set)) { +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; } - if (set.count == 0) { - return true; - } - if (set.elementClass != tables::kGearsetItemRowClass || set.count > domain::kPackageItemCapacity - || set.dataOffset + static_cast(set.count) * tables::kGearsetItemStride - > definition.size()) { + + // 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; } - 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) - || !tables::index_row(itemTable, itemRows, itemIndex, entry)) { + 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; } - package.items[package.itemCount++] = entry.definitionHash; } - return true; + + 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. */ @@ -75,7 +96,6 @@ bool build_season_pass(const reader::Source& source, Storage& storage, std::span root) noexcept { storage.seasonPassRewardCount = 0; - storage.seasonPassPackageCount = 0; std::uint32_t itemTableTag = 0; tables::Array itemRows{}; @@ -87,7 +107,6 @@ bool build_season_pass(const reader::Source& source, || itemRows.elementClass != tables::kItemIndexTableClass) { return false; } - const std::span itemTable{storage.itemIndexTable}; std::uint32_t tableTag = 0; tables::Array progressions{}; @@ -97,74 +116,49 @@ 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 = {}; - 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; - return false; + 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; } - 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{}; - package.definitionHash = entry.definitionHash; - if (storage.seasonPassPackageCount < domain::kPackageCapacity - && std::none_of( - held.begin(), - held.end(), - [&entry](const domain::Package& row) { - return row.definitionHash == entry.definitionHash; - }) - && reader::read_tag(source, storage.scratch, entry.targetTag, storage.definition) - && read_package( - std::span{storage.definition}, itemTable, itemRows, package) - && 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/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 e2e83ab68..e926ff11a 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp @@ -1,8 +1,9 @@ #include "definition_index_table.h" -#include #include +#include "field_reader.h" + namespace sunrise::middleware::content::packages::tables { namespace { @@ -31,17 +32,6 @@ constexpr std::size_t kIndexRowTagOffset = 16; /** Arrays larger than this are not definition tables. */ constexpr std::uint64_t kMaximumArrayCount = 300000; -/** @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; -} - /** * Checks one array header reached from a descriptor. * @param blob Whole definition bytes. @@ -137,6 +127,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 a1aa1502e..7cd162b7f 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h @@ -96,7 +96,10 @@ inline constexpr std::size_t kUnlockInstructionOperandOffset = 4; /** An expression field is a 64-bit count then a 64-bit self-relative offset. */ inline constexpr std::size_t kUnlockExpressionFieldSize = 16; inline constexpr std::size_t kUnlockExpressionPointerOffset = 8; -/** Opcodes run to fifteen; anything wider means the field is not an expression. */ +/** + * Record and node field detection accepts a field only when every opcode is at most 20. This is a + * detection bound, not the opcode range: installed reward conditions also use opcode 22. + */ inline constexpr std::uint32_t kUnlockOpcodeCeiling = 20; /** The opcode that reads a value slot. */ inline constexpr std::uint32_t kUnlockReadValueOpcode = 10; @@ -104,6 +107,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; /** The opcode that inverts the value on top of the stack. Its operand is unused. */ @@ -130,6 +135,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. */ @@ -223,10 +233,9 @@ 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. */ -inline constexpr std::size_t kGearsetItemField = 392; -inline constexpr std::uint32_t kGearsetItemRowClass = 0x808087DBU; -inline constexpr std::size_t kGearsetItemStride = 2; +/** Eligibility expressions and socket overrides carried by one progression reward. */ +inline constexpr std::size_t kProgressionRewardConditionsOffset = 24; +inline constexpr std::size_t kProgressionRewardSocketsOffset = 40; /** Expression that makes one collectible acquired, which a purchase or a grant sets. */ inline constexpr std::size_t kCollectibleAcquiredExpressionField = 112; @@ -283,6 +292,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/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 148327cea..5b8873d6a 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 80baf576e..4901881ef 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -529,22 +529,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, @@ -577,43 +564,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/internal.h b/Sunrise/src/server/bap/encrypted/internal.h index 197bde99d..5ba1a0301 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. */ @@ -481,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..b28fead96 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)() @@ -346,7 +163,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 +220,9 @@ bool prepare_record_reward_grant( } const auto accountBytes = rawStorage.first(account_layout::kObjectSize); - if (!family4_datagen::account::encode(account, accountBytes)) { + state::unlocks::Table unlocks{}; + if (!state::preview_reward_unlocks(mutation, unlocks) + || !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 a35d2f946..fc8d3a119 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()); @@ -696,6 +785,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/internal.h b/Sunrise/src/server/bap/internal.h index ac99ff4b5..e265b7bdb 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{}; @@ -536,7 +544,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. */ @@ -555,13 +563,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/state/account/account_state.h b/Sunrise/src/state/account/account_state.h index b76d74f94..13be4ee26 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 5564f1c07..416fb743d 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" @@ -113,6 +114,12 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept { if (status != cache::LoadStatus::loaded || !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) @@ -124,8 +131,7 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept { || !catalystsReplaced || !abilities::replace(domains.abilityBuckets) || !progressions::replace(domains.progressions, domains.progressionSteps) // An empty catalog is complete: a build with no installed pass declares no reward. - || (!domains.seasonPassRewards.empty() - && !season_pass::replace(domains.seasonPassRewards, domains.seasonPassPackages)) + || (!domains.seasonPassRewards.empty() && !season_pass::replace(domains.seasonPassRewards)) || !bounties::replace(domains.bounties) || !records::replace(domains.records, domains.recordObjectives, 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 cd2b0f1ae..b25d51e61 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 @@ -53,8 +53,13 @@ namespace { && counts.recordRewards <= output.recordRewards.size() && 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. */ @@ -94,8 +99,13 @@ namespace { header.recordRewardCount, header.progressionStepCount, 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..ba407f9d7 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 @@ -123,9 +123,14 @@ void clear(records::MutableDomains output) noexcept { std::fill(output.progressionSteps.begin(), output.progressionSteps.end(), progressions::Step{}); std::fill( output.seasonPassRewards.begin(), output.seasonPassRewards.end(), season_pass::Reward{}); - 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. */ @@ -167,8 +172,13 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe && add_records(counts.recordRewards, sizeof(records::RecordRewardRecord), size) && 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. */ @@ -282,12 +292,28 @@ bool read_payload(HANDLE file, valid = valid && read_domain( file, output.seasonPassRewards.first(counts.seasonPassRewards), checksum); - valid = valid - && read_domain( - file, output.seasonPassPackages.first(counts.seasonPassPackages), checksum); 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; } @@ -330,8 +356,13 @@ bool read_payload(HANDLE file, output.recordRewards.first(counts.recordRewards), output.progressionSteps.first(counts.progressionSteps), 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..0efe0c2ec 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" @@ -169,8 +170,13 @@ template && counts.recordRewards <= domains.recordRewards.size() && 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 +242,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) @@ -249,8 +261,7 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept { || !strictly_ordered(domains.abilityBuckets, ability_less) || !progressions::valid(domains.progressions, domains.progressionSteps) // An empty catalog is complete: a build with no installed pass declares no reward. - || (!domains.seasonPassRewards.empty() - && !season_pass::valid(domains.seasonPassRewards, domains.seasonPassPackages)) + || (!domains.seasonPassRewards.empty() && !season_pass::valid(domains.seasonPassRewards)) || !bounties::valid(domains.bounties) || !build_data::records::valid(domains.records, domains.recordObjectives, @@ -276,11 +287,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..78f9003a6 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. */ @@ -98,13 +116,37 @@ 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.condition.size(); ++i) { + if (!encode(value.condition[i], record.condition[i])) { + return false; + } + } + for (std::size_t i = 0; i < record.sockets.size(); ++i) { + if (!encode(value.sockets[i], 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,30 +154,18 @@ bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) no value.itemIndex = record.itemIndex; value.claimFlagIndex = record.claimFlagIndex; value.requiredRank = record.requiredRank; - return true; -} - -/** Encodes one season pass wrapper with its unused item slots zeroed. */ -bool encode(const season_pass::Package& value, SeasonPassPackageRecord& record) noexcept { - if (value.itemCount > value.items.size()) { - return false; + 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; + } } - record = {}; - record.definitionHash = value.definitionHash; - record.items = value.items; - record.itemCount = value.itemCount; - return true; -} - -/** Decodes one season pass wrapper after checking its padding and item count. */ -bool decode(const SeasonPassPackageRecord& record, season_pass::Package& value) noexcept { - value = {}; - if (record.reserved != decltype(record.reserved){} || record.itemCount > record.items.size()) { - return false; + for (std::size_t i = 0; i < value.socketCount; ++i) { + if (!decode(record.sockets[i], value.sockets[i])) { + return false; + } } - value.definitionHash = record.definitionHash; - value.items = record.items; - value.itemCount = record.itemCount; 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/codec.h b/Sunrise/src/state/build_data/cache/records/codec.h index d535bbc78..58dc06b99 100644 --- a/Sunrise/src/state/build_data/cache/records/codec.h +++ b/Sunrise/src/state/build_data/cache/records/codec.h @@ -95,11 +95,6 @@ namespace sunrise::state::build_data::cache::records { [[nodiscard]] bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) noexcept; -[[nodiscard]] bool encode(const season_pass::Package& value, - SeasonPassPackageRecord& record) noexcept; -[[nodiscard]] bool decode(const SeasonPassPackageRecord& record, - season_pass::Package& value) noexcept; - [[nodiscard]] bool encode(const bounties::Definition& value, BountyRecord& record) noexcept; [[nodiscard]] bool decode(const BountyRecord& record, bounties::Definition& value) noexcept; @@ -165,4 +160,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..35845c336 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" @@ -65,8 +66,13 @@ struct DomainCounts { std::size_t recordRewards{}; std::size_t progressionSteps{}; 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. */ @@ -108,8 +114,13 @@ struct MutableDomains { std::span recordRewards; std::span progressionSteps; 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. */ @@ -150,8 +161,13 @@ struct Domains { std::span recordRewards; std::span progressionSteps; 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 6100e824b..d60220e76 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" @@ -31,7 +32,7 @@ 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 = 66; +inline constexpr std::uint32_t kCacheFormatVersion = 72; /** 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. */ @@ -102,8 +103,13 @@ struct Header { std::uint32_t recordRewardCount{}; std::uint32_t progressionStepCount{}; 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{}; @@ -302,6 +308,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, so the packed instruction always matches. */ + std::uint16_t reserved{}; + std::uint32_t operand{}; +}; + /** Disk form of one season pass reward row. */ struct SeasonPassRewardRecord { std::uint32_t itemHash{}; @@ -309,19 +333,14 @@ struct SeasonPassRewardRecord { std::uint16_t itemIndex{}; std::uint16_t claimFlagIndex{season_pass::kUnavailableFlagIndex}; std::uint8_t requiredRank{}; + std::array sockets{}; + std::uint8_t socketCount{}; + std::array condition{}; + std::uint8_t conditionCount{}; /** Must be zero, so the packed reward row always matches. */ std::array reserved{}; }; -/** Disk form of one season pass wrapper item and the set it opens into. */ -struct SeasonPassPackageRecord { - std::uint32_t definitionHash{}; - std::array items{}; - std::uint8_t itemCount{}; - /** Must be zero, so the packed wrapper row always matches. */ - std::array reserved{}; -}; - /** Disk form of one repeatable bounty and the item-type its pool is keyed by. */ struct BountyRecord { std::uint32_t itemTypeBank{}; @@ -583,13 +602,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 target, quantity, weight and its 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) + 2 * sizeof(std::uint64_t) + == kCacheMagic.size() + 44 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t) + sizeof(InvestmentConstants) + sizeof(gameplay::entity_position_profiles::Fingerprint)); static_assert(sizeof(SpawnPointRecord) @@ -630,10 +714,9 @@ static_assert(sizeof(RosterGroupRecord) static_assert(sizeof(ProgressionRecord) == 2 * sizeof(std::uint16_t) + 2 * 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)); -static_assert(sizeof(SeasonPassPackageRecord) - == (1 + season_pass::kPackageItemCapacity) * sizeof(std::uint32_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(BountyRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t)); static_assert(sizeof(RecordDefinitionRecord) == sizeof(std::uint32_t) + 10 * sizeof(std::uint16_t) + 5 * 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..0e9882356 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 @@ -48,8 +48,13 @@ namespace { && domains.recordRewards.size() <= kMaximumCount && 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..911a0154c 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 @@ -101,9 +101,14 @@ bool payload_checksum(records::Domains domains, std::uint64_t& checksum) noexcep && checksum_domain(domains.recordRewards, checksum) && checksum_domain(domains.progressionSteps, checksum) && checksum_domain(domains.seasonPassRewards, checksum) - && checksum_domain(domains.seasonPassPackages, + && 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.bounties, checksum); + && checksum_domain(domains.rewardModifiers, checksum) + && checksum_domain(domains.rewardSockets, checksum); } /** Writes every array in the same order used by the payload checksum. */ @@ -143,8 +148,13 @@ bool write_payload(HANDLE file, records::Domains domains) noexcept { && write_domain(file, domains.recordRewards) && 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 5f4301683..5ee69b3b2 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 @@ -140,8 +140,13 @@ enum class WriteStatus { static_cast(domains.recordRewards.size()), static_cast(domains.progressionSteps.size()), 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/rewards/definition.h b/Sunrise/src/state/build_data/rewards/definition.h new file mode 100644 index 000000000..04dff5961 --- /dev/null +++ b/Sunrise/src/state/build_data/rewards/definition.h @@ -0,0 +1,121 @@ +#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}; + 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 ab72cb43a..5608e7a28 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" @@ -390,13 +391,14 @@ 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. */ -[[nodiscard]] bool publish_season_pass(std::span rewards, - std::span packages) noexcept; +[[nodiscard]] bool publish_season_pass(std::span rewards) 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. @@ -407,17 +409,38 @@ 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; + /** - * Finds the item set one season pass wrapper opens into. - * @param definitionHash Authored wrapper item hash. - * @param package Receives the wrapper and its items. - * @return True when the catalog is ready and a wrapper carries that hash. + * 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 find_season_pass_package(std::uint32_t definitionHash, - season_pass::Package& package) noexcept; +[[nodiscard]] bool publish_reward_definitions(rewards::View definitions) noexcept; -/** @return Season pass reward rows in State. */ -[[nodiscard]] std::size_t season_pass_reward_count() 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; /** @return True when the repeatable bounty table is in State. */ [[nodiscard]] bool repeatable_bounties_ready() noexcept; 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..602a019f7 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" @@ -372,6 +373,7 @@ void clear_catalogs() noexcept { rollback_ability_publication(); progressions::clear(); season_pass::clear(); + rewards::clear(); bounties::clear(); records::clear(); nodes::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..aabf7b6ad 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" @@ -90,8 +91,13 @@ to_record(const constants::InvestmentConstants& value) noexcept { && progressions::snapshot(scratch.progressions, counts.progressions) && progressions::snapshot_steps(scratch.progressionSteps, counts.progressionSteps) && 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 +131,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); } @@ -222,9 +229,6 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept { const auto seasonPassRewards = ensure_scratch( state.seasonPassRewardScratch); - const auto seasonPassPackages = - ensure_scratch( - state.seasonPassPackageScratch); const auto bountyRows = ensure_scratch(state.bountyScratch); const auto positionProfiles = ensure_scratch(state.rewardPoolScratch), + ensure_scratch(state.rewardEntryScratch), + ensure_scratch(state.rewardItemScratch), + ensure_scratch( + state.rewardInstructionScratch), + ensure_scratch(state.rewardModifierScratch), + ensure_scratch( + state.rewardSocketScratch), }; } @@ -299,8 +310,13 @@ void release_scratch_locked(Context& state) noexcept { release_bank(state.progressionScratch); release_bank(state.progressionStepScratch); 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); @@ -402,9 +418,16 @@ cache::records::Domains occupied_domains(Context& state, counts.progressionSteps}, std::span{state.seasonPassRewardScratch.data(), counts.seasonPassRewards}, - 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..2de8ae5ca 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 @@ -79,8 +79,13 @@ struct Context { std::vector progressionScratch{}; std::vector progressionStepScratch{}; 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..83bada05d 100644 --- a/Sunrise/src/state/build_data/season_pass/definition.h +++ b/Sunrise/src/state/build_data/season_pass/definition.h @@ -4,23 +4,22 @@ #include #include +#include "../rewards/definition.h" + namespace sunrise::state::build_data::season_pass { /** Reward rows the installed pass declares. The shipped build carries 196. */ inline constexpr std::size_t kRewardCapacity = 256; -/** Wrapper rewards that open into a set. The shipped pass carries one per character class. */ -inline constexpr std::size_t kPackageCapacity = 8; - -/** Items one wrapper opens into. The shipped wrappers each carry six. */ -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,15 +29,12 @@ struct Reward { std::uint16_t claimFlagIndex{kUnavailableFlagIndex}; /** Rank the account needs before the row may be claimed. */ std::uint8_t requiredRank{}; -}; - -/** One wrapper reward and the items it opens into. */ -struct Package { - /** Authored definition hash of the wrapper item. */ - std::uint32_t definitionHash{}; - /** Authored definition hashes the wrapper opens into, in declared order. */ - std::array items{}; - std::uint8_t itemCount{}; + /** 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{}; }; } // namespace sunrise::state::build_data::season_pass 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 42fa6dd66..b33424811 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,12 +9,16 @@ 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. */ -bool publish_season_pass(std::span rewards, - std::span packages) noexcept { +/** Publishes the season pass reward list with its fixed socket overrides. */ +bool publish_season_pass(std::span rewards) noexcept { runtime::persistence::Transaction transaction; return transaction.active() - && transaction.finish(season_pass::replace(rewards, packages), season_pass::clear); + && transaction.finish(season_pass::replace(rewards), 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. */ @@ -22,12 +26,6 @@ bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& rew return season_pass::find(rewardIndex, reward); } -/** Finds the item set one season pass wrapper opens into. */ -bool find_season_pass_package(std::uint32_t definitionHash, - season_pass::Package& package) noexcept { - return season_pass::find_package(definitionHash, package); -} - /** @return Season pass reward rows in State. */ std::size_t season_pass_reward_count() noexcept { return season_pass::count(); 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 b02d6038a..bb04f701c 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,105 +1,87 @@ #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 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. */ -bool valid(std::span rewards, std::span packages) noexcept { - if (rewards.empty() || rewards.size() > kRewardCapacity || packages.size() > kPackageCapacity) { +/** Checks one complete pass catalog; unavailable rows keep their native positions. */ +bool valid(std::span rewards) noexcept { + if (rewards.empty() || rewards.size() > kRewardCapacity) { 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; - }); - return granting && std::all_of(packages.begin(), packages.end(), [](const Package& package) { - return package.definitionHash != 0 && package.itemCount != 0 - && package.itemCount <= kPackageItemCapacity; - }); + 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 true; } /** Replaces the complete pass catalog in one step. */ -bool replace(std::span rewards, std::span packages) noexcept { - if (!valid(rewards, packages)) { +bool replace(std::span rewards) noexcept { + if (!valid(rewards)) { return false; } const std::lock_guard guard(g_lock); - const bool replaced = g_rewards.replace(rewards) && g_packages.replace(packages); - if (!replaced) { - g_rewards.clear(); - g_packages.clear(); - } - return replaced; + return g_rewards.replace(rewards); } -/** 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; } -/** Finds the wrapper package one item hash opens into. */ -bool find_package(std::uint32_t definitionHash, Package& package) noexcept { - package = {}; - const std::shared_lock guard(g_lock); - for (const Package& row : g_packages.rows()) { - if (row.definitionHash == definitionHash) { - package = row; - return true; - } - } - return false; -} - /** Copies every reward row in native reward order. */ bool snapshot(std::span output, std::size_t& count) noexcept { const std::shared_lock guard(g_lock); return g_rewards.snapshot(output, count); } -/** Copies every wrapper package in declared order. */ -bool snapshot_packages(std::span output, std::size_t& count) noexcept { - const std::shared_lock guard(g_lock); - 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(); } -/** @return The wrapper package count, read under the lock. */ -std::size_t package_count() noexcept { - const std::shared_lock guard(g_lock); - return g_packages.count(); -} - } // namespace sunrise::state::build_data::season_pass 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 21dd3a99b..bda9fc209 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,70 +1,22 @@ #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. */ 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. - */ -[[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. - * @param packages Complete wrapper packages. - * @return True when the catalog passes validation and fits fixed State storage. - */ -[[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. - */ +/** Checks row bounds; unavailable rows retain their native positions. */ +[[nodiscard]] bool valid(std::span rewards) noexcept; +/** Publishes a validated pass without reordering its claim indices. */ +[[nodiscard]] bool replace(std::span rewards) noexcept; +/** Copies one native claim row; clears the output when absent or unavailable. */ [[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. - */ -[[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. - * @param count Receives the copied row count, or zero when output is too small. - * @return True when output can hold every row. - */ +/** Copies the complete pass; count is zero when the destination is too small. */ [[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. - */ -[[nodiscard]] bool snapshot_packages(std::span output, std::size_t& count) noexcept; - -/** @return The reward row count, read under the lock. */ +/** Number of native positions, including unavailable rows. */ [[nodiscard]] std::size_t count() noexcept; -/** @return The wrapper package count, read under the lock. */ -[[nodiscard]] std::size_t package_count() noexcept; - } // namespace sunrise::state::build_data::season_pass 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/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 76525e31d..9282bdda4 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -5,13 +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" +namespace sunrise::state::unlocks { +struct Table; +} + namespace sunrise::state::account::settings { struct SettingsDelta; @@ -20,6 +26,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. @@ -201,34 +211,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. */ @@ -242,6 +241,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. */ @@ -256,6 +263,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{}; @@ -267,14 +276,14 @@ struct PendingRecordRewardGrant { 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{}; }; @@ -539,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; @@ -559,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, @@ -769,7 +793,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. */ @@ -779,7 +806,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 3abddbe91..65fd12f2e 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -247,100 +247,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. @@ -461,80 +367,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 /** @@ -565,13 +397,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..a4ea32ee2 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,480 @@ 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 { + 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; + } + } + 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 +518,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 +549,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 +571,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 +630,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 +782,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 +795,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 +824,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 +845,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 +859,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 +887,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 +910,35 @@ 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); + } + 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/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..715b296b7 --- /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 outside the saved banks. The operand is its identity hash. */ + external, +}; + +/** One bound expression instruction. */ +struct Instruction { + Opcode opcode{}; + Bank bank{Bank::none}; + /** Bank index, class index, external identity hash, 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