Просмотр исходного кода

[12] fix(inventory): require ownership of an applied socket plug

Review: stage_socket_plug does not check for plug ownership.
Resolution: a plug drawn from a finite profile stack must be held
before it applies.

stage_socket_plug checked that the socket accepts the plug and charged
the insertion cost, but never that the account had one. Compatibility
comes from the installed plug relation, which lists every plug the
socket can take rather than the ones the account holds, so any
compatible plug applied. A live capture confirmed it: four distinct
shaders applied against an account that owned one.

Ownership is only meaningful where the plug is a finite supply the
account draws down, so the check is scoped to exactly those. A shader
Collections can grant is one, because that is how it reaches a profile
stack. An ornament is a permanent unlock the account holds once earned,
which is why the Client offers every valid one for a socket, so
requiring a stack for one would refuse a plug the account already has.

A socket's default plug belongs to no stack and is not in Collections,
so clearing a socket is always available and costs nothing. Testing that
against the item's own factory plug is not enough: an item that ships
with a shader already applied has a different factory plug, and clearing
it back to the default is not a return to that factory state.

Rejections report plug_ownership, which separates them from the existing
compatibility failure in the log.

Thomas Shields 3 недель назад
Родитель
Сommit
b5f577f6a1

+ 14 - 0
Sunrise/src/state/build_data/collectibles/collectible_catalog.cpp

@@ -90,6 +90,20 @@ bool find(std::uint16_t collectibleIndex, Definition& definition) noexcept {
     return found;
 }
 
+/** Answers whether any published collectible grants one installed item row. */
+bool grants_item(std::uint16_t itemDefinitionIndex) noexcept {
+    if (itemDefinitionIndex == kUnavailableItemDefinitionIndex) {
+        return false;
+    }
+    const Lock::Shared guard(g_lock);
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.itemDefinitionIndex == itemDefinitionIndex) {
+            return true;
+        }
+    }
+    return false;
+}
+
 /** Copies the dense rows without exposing catalog storage. */
 bool snapshot(std::span<Definition> output, std::size_t& count) noexcept {
     const Lock::Shared guard(g_lock);

+ 7 - 0
Sunrise/src/state/build_data/collectibles/collectible_catalog.h

@@ -47,6 +47,13 @@ void clear() noexcept;
 /** Finds one collectible by the native 15-bit index carried by the request. */
 [[nodiscard]] bool find(std::uint16_t collectibleIndex, Definition& definition) noexcept;
 
+/**
+ * Answers whether any collectible grants one installed item row.
+ * @param itemDefinitionIndex Installed item-definition row.
+ * @return True when Collections can grant that item, so an account can come to own it.
+ */
+[[nodiscard]] bool grants_item(std::uint16_t itemDefinitionIndex) noexcept;
+
 /** Copies every row in native collectible-index order. */
 [[nodiscard]] bool snapshot(std::span<Definition> output, std::size_t& count) noexcept;
 

+ 18 - 2
Sunrise/src/state/build_data/items/socket_plugs/socket_plug_build_data_runtime.cpp

@@ -1,4 +1,5 @@
 #include "../../runtime.h"
+#include "../../collectibles/collectible_catalog.h"
 #include "../../runtime/persistence/publication_transaction.h"
 #include "../details/item_detail_catalog.h"
 #include "../item_catalog.h"
@@ -7,6 +8,11 @@
 namespace sunrise::state::build_data {
 namespace {
 
+/** Profile bucket holding mods and ornaments, which stay owned after they are applied. */
+constexpr std::uint8_t kModBucketId = 13;
+/** Profile bucket holding shaders, which are spent by the application. */
+constexpr std::uint8_t kShaderBucketId = 14;
+
 /** Checks every exact socket relation link against the already-published item details. */
 [[nodiscard]] bool
 valid_socket_plug_publication(std::span<const items::socket_plugs::Rule> rules,
@@ -59,10 +65,20 @@ bool is_socket_plug_allowed(std::uint16_t itemDefinitionIndex,
            && items::socket_plugs::allowed(itemDefinitionIndex, lane, plugDefinitionIndex);
 }
 
+/**
+ * Answers whether applying one plug spends a stack the account has to hold.
+ *
+ * Only a shader is spent: an ornament stays owned once applied. The plug also has to be
+ * one an account can come to own, which means Collections can grant it. A socket's default plug
+ * is not in Collections and belongs to no stack, so clearing a socket back to it costs nothing
+ * and is always available, whatever the item's own factory plug happens to be.
+ */
+bool is_consumed_on_apply(std::uint16_t itemDefinitionIndex, std::uint8_t bucketId) noexcept {
+    return bucketId == kShaderBucketId && collectibles::grants_item(itemDefinitionIndex);
+}
+
 /** Answers whether one installed profile row is a materializable socket action source. */
 bool is_profile_action_source(std::uint16_t itemDefinitionIndex, std::uint8_t bucketId) noexcept {
-    constexpr std::uint8_t kModBucketId = 13;
-    constexpr std::uint8_t kShaderBucketId = 14;
     items::details::Definition detail{};
     inventory::buckets::Descriptor bucket{};
     if ((bucketId != kModBucketId && bucketId != kShaderBucketId) || !socket_plug_rules_ready()

+ 10 - 0
Sunrise/src/state/build_data/runtime.h

@@ -185,6 +185,16 @@ publish_socket_plug_rules(std::span<const items::socket_plugs::Rule> rules,
 [[nodiscard]] bool is_profile_action_source(std::uint16_t itemDefinitionIndex,
                                             std::uint8_t bucketId) noexcept;
 
+/**
+ * Answers whether applying one plug spends a stack the account has to hold.
+ * @param itemDefinitionIndex Installed plug-definition row.
+ * @param bucketId Installed profile bucket the plug belongs to.
+ * @return True only for a shader Collections can grant. An ornament stays owned once applied,
+ *         and a socket's default plug belongs to no stack at all.
+ */
+[[nodiscard]] bool is_consumed_on_apply(std::uint16_t itemDefinitionIndex,
+                                        std::uint8_t bucketId) noexcept;
+
 /** @return True when the whole progression definition table is in State. */
 [[nodiscard]] bool progression_definitions_ready() noexcept;
 

+ 31 - 0
Sunrise/src/state/runtime/state_account_runtime.cpp

@@ -438,6 +438,23 @@ apply_collection_materials(const AccountState& before,
         changed);
 }
 
+/**
+ * Answers whether the account holds one applicable stack of a socket action source.
+ * @param account Account whose profile stacks are searched.
+ * @param definitionHash Plug definition the Client asked to apply.
+ * @return True when a profile stack of that definition holds at least one unit.
+ */
+[[nodiscard]] bool holds_plug_source(const AccountState& account,
+                                     std::uint32_t definitionHash) noexcept {
+    for (std::size_t index = 0; index < account.profileItemCount; ++index) {
+        const authored_inventory::ProfileItem& item = account.profileItems[index];
+        if (item.definitionHash == definitionHash && item.quantity > 0) {
+            return true;
+        }
+    }
+    return false;
+}
+
 /** Applies one dense installed action-cost set resolved from the selected plug or action row. */
 [[nodiscard]] bool
 apply_action_materials(const AccountState& before,
@@ -1158,6 +1175,20 @@ character_item_at(CharacterState& character, const CharacterItemLocation& locati
         return fail("definition_or_compatibility");
     }
 
+    // Ownership is only meaningful where the plug is a finite supply the account draws down. A
+    // shader is one: it is pulled from Collections into a profile stack and spent by applying it.
+    // An ornament is a permanent unlock the account holds once earned, not a stack it draws
+    // down, which is why the Client offers every valid one for a socket. Requiring a stack for
+    // one would refuse a plug the account already has.
+    const bool consumesStack =
+        build_data::is_profile_action_source(plugDefinitionIndex, plugDefinition.bucketId)
+        && build_data::is_consumed_on_apply(plugDefinitionIndex, plugDefinition.bucketId)
+        && !(socketLane < detail.initialPlugIndices.size()
+             && detail.initialPlugIndices[socketLane] == plugDefinitionIndex);
+    if (consumesStack && !holds_plug_source(snapshot, plugDefinition.definitionHash)) {
+        return fail("plug_ownership");
+    }
+
     AccountState chargedAccount = snapshot;
     build_data::material_requirements::Definition materialSet{};
     bool profileChanged = false;