Browse Source

Defer draw overflows, merge repeated stack draws, keep child wrapper flags, and match rewards to the item catalog

Zach 3 weeks ago
parent
commit
18cb9e4a55

+ 19 - 6
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -110,9 +110,18 @@ bool build_item_rows(const reader::Source& source,
         item.definitionHash = row.definitionHash;
         item.definitionIndex = static_cast<std::uint16_t>(index);
         std::uint32_t itemClass = 0;
-        if (!reader::read_tag(source, storage.scratch, row.targetTag, storage.definition, itemClass)
-            || !tables::items::read_definition(std::span<const std::byte>{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<const std::byte>{storage.definition}, item)) {
             continue;
         }
         if (rewardStorageReady && itemClass == tables::kItemDefinitionClass) {
@@ -166,9 +175,6 @@ bool build_item_rows(const reader::Source& source,
             append_initial_plugs(item, table.count, storage.detailRequests);
         }
     }
-    // A reward refusal is reported by RewardBuild and does not block the other item domains.
-    const bool rewardsPublished =
-        !needRewards || (rewardStorageReady && storage.rewardBuild.publish());
     bool requestsFit = true;
     if (needRows) {
         // Every walked entry either published a row or was skipped, so the count of one
@@ -184,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"

+ 23 - 1
Sunrise/src/client/content/items/packages/package_reward_build.cpp

@@ -698,6 +698,7 @@ bool RewardBuild::read_item(std::uint32_t hash,
 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;
@@ -724,10 +725,31 @@ void RewardBuild::item(std::uint16_t index,
     }
 }
 
-bool RewardBuild::publish() noexcept {
+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<unsigned>(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()) {

+ 6 - 2
Sunrise/src/client/content/items/packages/package_reward_build.h

@@ -79,8 +79,10 @@ public:
     [[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<const std::byte> blob) noexcept;
-    /** Drops rows naming items or pools that were not read, then publishes the banks. */
-    [[nodiscard]] bool publish() 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. */
@@ -95,6 +97,8 @@ private:
     bool supplementalMissing_{};
     std::size_t unboundAcquiredFlags_{};
     std::size_t discardedAcquiredFlags_{};
+    std::size_t unreadItems_{};
+    std::uint16_t firstUnreadItem_{};
     std::vector<state::build_data::rewards::Pool> pools_;
     std::vector<state::build_data::rewards::Entry> entries_;
     std::vector<state::build_data::rewards::Item> items_;

+ 28 - 21
Sunrise/src/state/rewards/reward_resolver.cpp

@@ -132,6 +132,8 @@ struct Resolver {
     AcquiredFlags& acquiredFlags;
     bool& excludedByUnlocks;
     bool& leafDrawn;
+    bool& leafFlagAcquired;
+    Resolution& failure;
     std::size_t wrapperDepth{};
     std::array<const definitions::Entry*, kDrawCapacity> selectedEntries{};
     std::array<std::uint32_t, kDrawCapacity> selectedCategories{};
@@ -273,6 +275,7 @@ struct Resolver {
             return draw(chosen->poolIndex, category, depth + 1, childTotal);
         }
         if (selectedCount == selectedEntries.size()) {
+            failure = Resolution::exceeded;
             return refuse(context, "reward_selection_capacity");
         }
         selectedEntries[selectedCount] = chosen;
@@ -329,18 +332,22 @@ struct Resolver {
                            acquiredFlags,
                            excludedByUnlocks,
                            childLeafDrawn,
+                           leafFlagAcquired,
+                           failure,
                            wrapperDepth + 1};
             if (!child.open_wrapper(item)) {
                 return false;
             }
-            // A child acquires its flag only when a draw inside it completed on a leaf row.
-            if (!childLeafDrawn) {
-                return true;
+            // 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;
             }
-            leafDrawn = true;
             return acquire_flag(item, true);
         }
         if (result.count == result.grants.size()) {
+            failure = Resolution::exceeded;
             return refuse(context, "grant_capacity");
         }
         leafDrawn = true;
@@ -389,7 +396,7 @@ bool resolve_item(definitions::View data,
                   std::uint16_t itemIndex,
                   std::uint32_t quantity,
                   Result& result,
-                  bool& ineligible) noexcept {
+                  Resolution& failure) noexcept {
     result = {};
     if (itemIndex >= data.items.size() || quantity == 0 || quantity > kMaximumQuantity) {
         return refuse(context, "reward_shape");
@@ -413,6 +420,7 @@ bool resolve_item(definitions::View data,
         AcquiredFlags acquiredFlags{};
         bool excludedByUnlocks = false;
         bool leafDrawn = false;
+        bool leafFlagAcquired = false;
         Resolver resolver{data,
                           context,
                           staged,
@@ -420,12 +428,15 @@ bool resolve_item(definitions::View data,
                           wrapperSeed,
                           acquiredFlags,
                           excludedByUnlocks,
-                          leafDrawn};
+                          leafDrawn,
+                          leafFlagAcquired,
+                          failure};
         if (!resolver.open_wrapper(item)) {
             return false;
         }
-        if (staged.count == 0 && staged.wrapperFlags.none()) {
-            ineligible = excludedByUnlocks && !leafDrawn;
+        if (staged.count == 0 && !leafFlagAcquired) {
+            failure =
+                excludedByUnlocks && !leafDrawn ? Resolution::ineligible : Resolution::refused;
             return refuse(context, "empty_reward");
         }
         if (!resolver.acquire_flag(item, true)) {
@@ -456,25 +467,21 @@ Resolution resolve(const Context& context,
         std::uint16_t item;
         std::uint32_t quantity;
         Result& result;
-        bool ineligible;
-    } request{context, itemIndex, quantity, result, false};
+        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<Request*>(raw);
-                                                return resolve_item(data,
-                                                                    value.context,
-                                                                    value.item,
-                                                                    value.quantity,
-                                                                    value.result,
-                                                                    value.ineligible);
-                                            })) {
+    if (build_data::read_reward_definitions(
+            &request, [](void* raw, definitions::View data) noexcept {
+                auto& value = *static_cast<Request*>(raw);
+                return resolve_item(
+                    data, value.context, value.item, value.quantity, value.result, value.failure);
+            })) {
         return Resolution::resolved;
     }
-    return request.ineligible ? Resolution::ineligible : Resolution::refused;
+    return request.failure;
 }
 
 } // namespace sunrise::state::reward_resolution

+ 2 - 2
Sunrise/src/state/rewards/reward_resolver.h

@@ -38,8 +38,8 @@ struct Context {
                             const Context& context,
                             bool& result) noexcept;
 
-/** How one resolution ended; ineligible means the saved unlocks left nothing to draw. */
-enum class Resolution : std::uint8_t { resolved, ineligible, refused };
+/** 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,

+ 50 - 2
Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp

@@ -165,6 +165,43 @@ apply_reward_sockets(const item_details::Definition& detail,
                                 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];
+        item_details::Definition detail{};
+        const bool stackable =
+            grant.socketCount == 0
+            && build_data::find_configured_item_detail(grant.itemIndex, detail)
+            && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable;
+        auto* const merged = stackable ? kept_stack(resolved, kept, grant.itemIndex) : nullptr;
+        if (merged == nullptr) {
+            resolved.grants[kept++] = grant;
+            continue;
+        }
+        if (static_cast<std::int64_t>(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()) {
@@ -225,6 +262,12 @@ enum class PassResolution { claim, replay };
         != 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) {
@@ -387,13 +430,18 @@ RewardPreparation prepare_item_reward(std::uint16_t itemIndex,
         itemIndex,
         quantity,
         resolved);
-    // The saved unlocks can change, so an empty draw they caused is retried, not retired.
-    if (resolution == reward_resolution::Resolution::ineligible) {
+    // 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);
 }