Преглед на файлове

Let a pursuit be placed, held and discarded

A pursuit - quest step, bounty, token - names no equipment slot because
nothing equips it. The loadout resolver demanded one for every item, so
a granted pursuit was added to the inventory and then could not be
found in the resolved loadout. Unequipped inventory no longer requires a
slot; equipped items still do, since the slot is what identifies them.

holds_pursuit is the rule for whether a character already holds one:
gear is told apart by carrying an equipment slot, consumables by a stack
larger than one. The stack size is what separates the second group, not
the instanced state - a pursuit carries no instance data and is marked
stackable exactly as a consumable is. It lives in State because it
mirrors the classification the client's native vendor-row gate applies.

Dismantling accepts a stackable quest step while the row holds one and
a slotless pursuit at the resolver's default slot. It also stops
restamping the surviving rows' serials, which had reshuffled the bucket
on screen every time an item was removed.
chnsw преди 6 дни
родител
ревизия
33b3a7ac65

+ 2 - 0
Sunrise/Sunrise.vcxproj

@@ -611,6 +611,7 @@
     <ClCompile Include="src\state\matchmaking\transactions\matchmaking_prepare.cpp" />
     <ClCompile Include="src\state\matchmaking\transactions\matchmaking_commit.cpp" />
     <ClCompile Include="src\state\runtime\equipment\configured_equipment_identity.cpp" />
+    <ClCompile Include="src\state\account\pursuit_hold.cpp" />
     <ClCompile Include="src\state\account\account_state.cpp" />
     <ClCompile Include="src\state\account\inventory\inventory_state.cpp" />
     <ClCompile Include="src\state\account\settings\settings_state.cpp" />
@@ -1399,6 +1400,7 @@
     <ClInclude Include="src\state\steam\steam_state.h" />
     <ClInclude Include="src\state\matchmaking\transactions\internal.h" />
     <ClInclude Include="src\state\runtime\equipment\configured_equipment_identity.h" />
+    <ClInclude Include="src\state\account\pursuit_hold.h" />
     <ClInclude Include="src\state\account\account_state.h" />
     <ClInclude Include="src\state\account\inventory\inventory_state.h" />
     <ClInclude Include="src\state\account\settings\settings_state.h" />

+ 11 - 2
Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp

@@ -125,6 +125,7 @@ bool resolve_item(const authored_inventory::Item& authored,
                   const state::CharacterState& character,
                   std::size_t itemDefinitionCount,
                   std::size_t socketEntryListCount,
+                  bool requireEquipmentSlot,
                   Candidate& output) noexcept {
     if (!authored_inventory::valid(authored) || itemDefinitionCount == 0
         || itemDefinitionCount > build_items::kDefinitionCapacity || socketEntryListCount == 0
@@ -141,8 +142,14 @@ bool resolve_item(const authored_inventory::Item& authored,
         || !state::build_data::find_configured_item_detail(itemDefinition.definitionIndex,
                                                            itemDetail)
         || itemDefinition.bucketId != itemDetail.bucketId
-        || !authored_inventory::resolve_native_equipment_slot(
-            authored.definitionHash, itemDetail.equipmentSlot, nativeEquipmentSlot)
+        // A pursuit - a bounty or a quest step - names no equipment slot, because nothing equips
+        // it. Requiring one refused it here, so it was added to the inventory and then could not
+        // be found in the resolved loadout, and the acquisition failed as `resolve_or_bucket_full`.
+        // Equipped items still must name a slot: they come out of the equipment array, where the
+        // slot is what identifies them.
+        || (!authored_inventory::resolve_native_equipment_slot(
+                authored.definitionHash, itemDetail.equipmentSlot, nativeEquipmentSlot)
+            && (requireEquipmentSlot || itemDetail.equipmentSlot.has_value()))
         || static_cast<std::size_t>(nativeEquipmentSlot) >= build_details::kEquipmentSlotCount
         || !state::build_data::find_inventory_bucket_descriptor(itemDetail.bucketId, bucket)
         || bucket.arraySelector != build_buckets::ArraySelector::character
@@ -155,6 +162,8 @@ bool resolve_item(const authored_inventory::Item& authored,
 
     Candidate candidate{};
     candidate.bucket = bucket;
+    // Slot zero for a slotless item is safe: the encoder reads `equipmentSlot` only when `equipped`
+    // is set, and only items resolved out of the equipment array are ever equipped.
     candidate.item.equipmentSlot = nativeEquipmentSlot;
     candidate.item.mutationSerial = authored.mutationSerial;
     candidate.item.flags = authored.flags;

+ 4 - 0
Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.h

@@ -22,6 +22,9 @@ struct Candidate {
  * @param character Authored character that owns the item.
  * @param itemDefinitionCount Stable dense item-table row count.
  * @param socketEntryListCount Stable dense socket-list row count.
+ * @param requireEquipmentSlot True for an item resolved out of an equipment slot, which must name
+ *        one. False for unequipped inventory, where a pursuit - a bounty or a quest - carries no
+ *        equipment slot at all and would otherwise be refused, leaving it unplaceable.
  * @param output Receives a complete candidate only on success.
  * @return True when every base, plug, bucket, and initial socket mapping resolves.
  */
@@ -29,6 +32,7 @@ struct Candidate {
                                 const state::CharacterState& character,
                                 std::size_t itemDefinitionCount,
                                 std::size_t socketEntryListCount,
+                                bool requireEquipmentSlot,
                                 Candidate& output) noexcept;
 
 } // namespace sunrise::middleware::datagen::family4::loadout

+ 9 - 3
Sunrise/src/middleware/datagen/family4/loadout/loadout_resolver.cpp

@@ -104,7 +104,7 @@ namespace build_buckets = state::build_data::inventory::buckets;
         Candidate candidate{};
         if (itemCount >= resolved.size()
             || !resolve_item(
-                *authored, character, itemDefinitionCount, socketEntryListCount, candidate)
+                *authored, character, itemDefinitionCount, socketEntryListCount, true, candidate)
             || !place_item(candidate, occupied, resolved[itemCount])) {
             return false;
         }
@@ -118,6 +118,7 @@ namespace build_buckets = state::build_data::inventory::buckets;
                                  character,
                                  itemDefinitionCount,
                                  socketEntryListCount,
+                                 false,
                                  candidate)
                 || !place_item(candidate, occupied, resolved[itemCount])) {
                 return false;
@@ -208,8 +209,12 @@ bool resolve(const state::AccountState& account,
             }
             instanceSoids[instanceSoidCount++] = authored->instanceSoid;
             Candidate candidate{};
-            if (!resolve_item(
-                    *authored, character, itemDefinitionCount, socketEntryListCount, candidate)
+            if (!resolve_item(*authored,
+                              character,
+                              itemDefinitionCount,
+                              socketEntryListCount,
+                              true,
+                              candidate)
                 || !record_equipment_slot(semanticIndex,
                                           candidate.item.equipmentSlot,
                                           semanticToNative,
@@ -240,6 +245,7 @@ bool resolve(const state::AccountState& account,
                                  character,
                                  itemDefinitionCount,
                                  socketEntryListCount,
+                                 false,
                                  selectedInventory[selectedInventoryCount])) {
                 return false;
             }

+ 69 - 0
Sunrise/src/state/account/pursuit_hold.cpp

@@ -0,0 +1,69 @@
+#include "pursuit_hold.h"
+
+#include <cstddef>
+
+#include "../../core/logging/log.h"
+#include "../build_data/runtime.h"
+#include "../runtime/runtime.h"
+#include "account_state.h"
+
+namespace sunrise::state::account {
+namespace {
+
+namespace detail_domain = build_data::items::details;
+
+/**
+ * Logs the fields the pursuit rule turns on, so a misclassification can be read off the values.
+ * @param itemDefinitionIndex Item being classified.
+ * @param detail Its configured detail row.
+ */
+void report_classification(std::uint16_t itemDefinitionIndex,
+                           const detail_domain::Definition& detail) noexcept {
+    core::log::writef(core::log::Channel::state,
+                      core::log::Level::debug,
+                      "ev=pursuit stage=classify item=%u bucket=%u slot=%d instanced=%u "
+                      "max_stack=%d",
+                      static_cast<unsigned>(itemDefinitionIndex),
+                      static_cast<unsigned>(detail.bucketId),
+                      detail.equipmentSlot.has_value() ? static_cast<int>(*detail.equipmentSlot)
+                                                       : -1,
+                      static_cast<unsigned>(detail.instancedDefinitionState),
+                      detail.maxStackSize);
+}
+
+} // namespace
+
+/** Reports whether an item is a pursuit the selected character already holds. */
+bool holds_pursuit(std::uint16_t itemDefinitionIndex) noexcept {
+    return holds_pursuit(account_snapshot(), itemDefinitionIndex);
+}
+
+/** The same rule, against an account view the caller already holds. */
+bool holds_pursuit(const AccountState& account, std::uint16_t itemDefinitionIndex) noexcept {
+    detail_domain::Definition detail{};
+    if (!build_data::find_configured_item_detail(itemDefinitionIndex, detail)) {
+        return false;
+    }
+    report_classification(itemDefinitionIndex, detail);
+    if (detail.equipmentSlot.has_value() || detail.maxStackSize > 1) {
+        return false;
+    }
+    build_data::items::Definition definition{};
+    if (!build_data::find_item_definition_index(itemDefinitionIndex, definition)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        const CharacterState& character = account.characters[index];
+        if (!character.selected) {
+            continue;
+        }
+        for (std::size_t item = 0; item < character.inventory.count; ++item) {
+            if (character.inventory.values[item].definitionHash == definition.definitionHash) {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+} // namespace sunrise::state::account

+ 39 - 0
Sunrise/src/state/account/pursuit_hold.h

@@ -0,0 +1,39 @@
+#pragma once
+
+#include <cstdint>
+
+#include "account_state.h"
+
+namespace sunrise::state::account {
+
+/**
+ * Reports whether an item is a pursuit the selected character already holds.
+ *
+ * A pursuit - quest, bounty, token - is unique per character. Two kinds of item are deliberately
+ * not pursuits, because holding several of each is legitimate: gear, told apart by carrying an
+ * equipment slot, and consumables and materials, told apart by declaring a stack larger than one.
+ * The stack size is what separates the second group, not the instanced state: a pursuit carries no
+ * instance data either, so it is marked stackable exactly as a consumable is.
+ *
+ * This lives in State because it mirrors the classification the client's native vendor-row gate
+ * applies locally when deciding whether a row is still offered, and the two must not drift.
+ *
+ * @param itemDefinitionIndex Item to classify.
+ * @return True when this is a pursuit the selected character already holds.
+ */
+[[nodiscard]] bool holds_pursuit(std::uint16_t itemDefinitionIndex) noexcept;
+
+/**
+ * The same rule, against an account view the caller already holds.
+ *
+ * Reading the account copies the whole of it, so a walk over many candidates - a bounty roll tests
+ * every item in the vendor's pool - takes one view and reuses it rather than copying per candidate.
+ *
+ * @param account Account view to test against.
+ * @param itemDefinitionIndex Item to classify.
+ * @return True when this is a pursuit that view's selected character already holds.
+ */
+[[nodiscard]] bool holds_pursuit(const AccountState& account,
+                                 std::uint16_t itemDefinitionIndex) noexcept;
+
+} // namespace sunrise::state::account

+ 20 - 29
Sunrise/src/state/runtime/state_account_dismantle_staging.cpp

@@ -500,6 +500,12 @@ apply_dismantle_rewards(const AccountState& before,
         return false;
     }
 
+    // Every survivor must still resolve to the same equipment lane, and how many the removal
+    // shifted is reported so a dismantle that rearranged a bucket stays visible. Their ordering
+    // tokens are deliberately left alone: the serial on an unequipped row is also the Client's
+    // ordering token for its bucket, and a fresh serial moves the item to the first cell. The rows
+    // below a dismantled item shift up only because the array closed the gap; stamping them
+    // reshuffled the bucket on screen. The whole character is republished, so the shift still lands.
     std::size_t movedItemCount = 0;
     for (std::size_t index = 0; index < after.inventory.count; ++index) {
         const std::uint64_t survivorSoid = after.inventory.values[index].instanceSoid;
@@ -515,31 +521,6 @@ apply_dismantle_rewards(const AccountState& before,
         movedItemCount += static_cast<std::size_t>(beforeRow != afterRow);
     }
 
-    // The serial is signed on the wire, so it must stay inside the positive int32 range.
-    constexpr std::uint32_t kMaximumInventorySerial =
-        static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)());
-    if (after.nextInventorySerial > kMaximumInventorySerial
-        || movedItemCount > kMaximumInventorySerial - after.nextInventorySerial) {
-        return false;
-    }
-
-    for (std::size_t index = 0; index < after.inventory.count; ++index) {
-        const std::uint64_t survivorSoid = after.inventory.values[index].instanceSoid;
-        std::uint16_t beforeRow = 0;
-        std::uint16_t afterRow = 0;
-        std::uint8_t beforeSlot = 0;
-        std::uint8_t afterSlot = 0;
-        if (!find_unequipped_row(beforeLoadout, survivorSoid, beforeRow, beforeSlot)
-            || !find_unequipped_row(placedAfter, survivorSoid, afterRow, afterSlot)
-            || beforeSlot != afterSlot) {
-            return false;
-        }
-        if (beforeRow != afterRow) {
-            after.inventory.values[index].mutationSerial =
-                static_cast<std::int32_t>(after.nextInventorySerial++);
-        }
-    }
-
     candidate.characters[characterIndex] = after;
     family4_loadout::ResolvedLoadout checkedAfter{};
     if (!account::valid(candidate)
@@ -570,10 +551,20 @@ apply_dismantle_rewards(const AccountState& before,
         || dismantledDetail.definitionIndex != dismantledDefinition.definitionIndex
         || dismantledDetail.definitionHash != dismantledDefinition.definitionHash
         || dismantledDetail.bucketId != dismantledDefinition.bucketId
-        || dismantledDetail.instancedDefinitionState
-               != item_details::InstancedDefinitionState::instanced
-        || !dismantledDetail.equipmentSlot.has_value()
-        || static_cast<std::uint8_t>(*dismantledDetail.equipmentSlot) != dismantledSlot) {
+        // A quest step is authored stackable, not instanced - in the pursuit bucket only bounties
+        // and containers set the instanced flag - so it is accepted while the row holds exactly
+        // one. A larger stack stays refused: decrementing one is a different mutation.
+        || (dismantledDetail.instancedDefinitionState
+                != item_details::InstancedDefinitionState::instanced
+            && dismantledItem.quantity != 1)
+        // A pursuit names no equipment slot, because nothing equips it. The loadout resolver stands
+        // such an item at slot zero, so compare against that default rather than demand a slot.
+        // Native slot zero is the subclass slot, which `gear_class_of` maps to no gear class, so a
+        // dismantled pursuit pays out nothing.
+        || (dismantledDetail.equipmentSlot.has_value()
+                ? static_cast<std::uint8_t>(*dismantledDetail.equipmentSlot)
+                : std::uint8_t{0})
+               != dismantledSlot) {
         return false;
     }