Browse Source

Merge pull request #114 from mozoii/feature/vendor-sale-charge

Charge vendor sale rows their own cost (Tower vendors)
stan 2 days ago
parent
commit
00d40ddda7

+ 9 - 3
Sunrise/src/client/content/vendors/layout.h

@@ -19,12 +19,18 @@ inline constexpr std::size_t kResetIntervalOffset = 20;
 /** Raw reset phase, paired with the interval. */
 /** Raw reset phase, paired with the interval. */
 inline constexpr std::size_t kResetPhaseOffset = 24;
 inline constexpr std::size_t kResetPhaseOffset = 24;
 
 
-/** Sale row price-override array descriptor, which is what the row charges. */
+/** Sale row cost array descriptor, which is what the row charges. */
 inline constexpr std::size_t kSaleCostArrayDescriptor = 32;
 inline constexpr std::size_t kSaleCostArrayDescriptor = 32;
-/** Cost item-definition index inside one price-override row. */
+/** Cost entry item-definition index. */
 inline constexpr std::size_t kSaleCostItemIndexOffset = 0;
 inline constexpr std::size_t kSaleCostItemIndexOffset = 0;
-/** Units the price-override row charges. */
+/** Cost entry static quantity. */
 inline constexpr std::size_t kSaleCostQuantityOffset = 4;
 inline constexpr std::size_t kSaleCostQuantityOffset = 4;
+/** Cost entry descriptor of its first expression array. An entry carrying one is not static. */
+inline constexpr std::size_t kSaleCostFirstProgramDescriptor = 8;
+/** Cost entry descriptor of its second expression array. An entry carrying one is not static. */
+inline constexpr std::size_t kSaleCostSecondProgramDescriptor = 24;
+/** Cost entry trailing word, which every expression-free entry carries as `kPlainCostWord`. */
+inline constexpr std::size_t kSaleCostWordOffset = 40;
 /** Sale row main item-definition index. */
 /** Sale row main item-definition index. */
 inline constexpr std::size_t kSaleItemIndexOffset = 70;
 inline constexpr std::size_t kSaleItemIndexOffset = 70;
 /** Sale row vendor category index. */
 /** Sale row vendor category index. */

+ 59 - 16
Sunrise/src/client/content/vendors/package_vendor_build.cpp

@@ -31,6 +31,10 @@ struct Storage {
     std::size_t definitionCount{};
     std::size_t definitionCount{};
     std::size_t saleRowCount{};
     std::size_t saleRowCount{};
     std::size_t installedRowCount{};
     std::size_t installedRowCount{};
+    /** Sale rows whose price depends on an expression, kept but never charged. */
+    std::size_t conditionalRows{};
+    /** Sale rows whose cost array did not read, kept but never charged. */
+    std::size_t unreadableRows{};
 };
 };
 
 
 /** One array a definition or a sale row declares, reduced to what the catalog stores. */
 /** One array a definition or a sale row declares, reduced to what the catalog stores. */
@@ -89,27 +93,51 @@ read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept
 }
 }
 
 
 /**
 /**
- * Reads what one sale row charges, from the first row of its price-override array.
- * A row charging nothing declares no override, which is data rather than a malformed row.
+ * Reads what one sale row charges: every entry of its cost array, and whether those entries are
+ * the price. An entry is static only when both of its expression arrays are empty and its
+ * trailing word is the plain value. One carrying an expression, or another word, makes the row
+ * conditional: its price then depends on state this build does not evaluate, so the entries are
+ * kept for the record and the row is never charged.
  * @param blob Whole definition blob.
  * @param blob Whole definition blob.
  * @param at Sale row offset inside the blob.
  * @param at Sale row offset inside the blob.
- * @param value Receives the cost item and quantity, or the absent cost.
- * @return True when the array is absent, or resolves and ends inside the blob.
+ * @param value Receives the entries and the row's price state.
+ * @return True when the array is absent, or resolves, fits, and ends inside the blob. Otherwise
+ *         the row is left unreadable, with no entries.
  */
  */
 [[nodiscard]] bool
 [[nodiscard]] bool
 read_sale_cost(std::span<const std::byte> blob, std::size_t at, domain::SaleRow& value) noexcept {
 read_sale_cost(std::span<const std::byte> blob, std::size_t at, domain::SaleRow& value) noexcept {
-    value.costItemIndex = domain::kAbsentCostItem;
-    value.costQuantity = 0;
+    value.costs = {};
+    value.costCount = 0;
+    value.priceState = domain::PriceState::unreadable;
     ArrayView cost{};
     ArrayView cost{};
     if (!read_array(blob, at + kSaleCostArrayDescriptor, domain::kSaleCostRowStride, cost)) {
     if (!read_array(blob, at + kSaleCostArrayDescriptor, domain::kSaleCostRowStride, cost)) {
         return false;
         return false;
     }
     }
-    if (cost.count == 0) {
-        return true;
+    if (cost.count != 0
+        && (cost.classId != domain::kSaleCostRowClass || cost.count > value.costs.size())) {
+        return false;
+    }
+    bool conditional = false;
+    for (std::size_t entry = 0; entry < cost.count; ++entry) {
+        const std::size_t entryAt = cost.base + (entry * domain::kSaleCostRowStride);
+        domain::SaleCost& output = value.costs[entry];
+        std::uint64_t firstProgram = 0;
+        std::uint64_t secondProgram = 0;
+        std::uint32_t word = 0;
+        if (!read(blob, entryAt + kSaleCostItemIndexOffset, output.itemIndex)
+            || !read(blob, entryAt + kSaleCostQuantityOffset, output.quantity)
+            || !read(blob, entryAt + kSaleCostFirstProgramDescriptor, firstProgram)
+            || !read(blob, entryAt + kSaleCostSecondProgramDescriptor, secondProgram)
+            || !read(blob, entryAt + kSaleCostWordOffset, word)) {
+            value.costs = {};
+            return false;
+        }
+        conditional = conditional || firstProgram != 0 || secondProgram != 0
+                      || word != domain::kPlainCostWord;
     }
     }
-    return cost.classId == domain::kSaleCostRowClass
-           && read(blob, cost.base + kSaleCostItemIndexOffset, value.costItemIndex)
-           && read(blob, cost.base + kSaleCostQuantityOffset, value.costQuantity);
+    value.costCount = static_cast<std::uint8_t>(cost.count);
+    value.priceState = conditional ? domain::PriceState::conditional : domain::PriceState::plain;
+    return true;
 }
 }
 
 
 /**
 /**
@@ -146,6 +174,8 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
 
 
 /**
 /**
  * Reads every sale row of one definition into the flat bank.
  * Reads every sale row of one definition into the flat bank.
+ * A row whose cost array does not read costs that row its price, never the vendor: a purchase
+ * names the row by ordinal, so the row stays in place and refuses to be bought.
  * @param blob Whole definition blob.
  * @param blob Whole definition blob.
  * @param definition Definition whose sale array was already resolved.
  * @param definition Definition whose sale array was already resolved.
  * @param storage Pass storage receiving the rows.
  * @param storage Pass storage receiving the rows.
@@ -163,10 +193,20 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
         value = {};
         value = {};
         if (!read(blob, at + kSaleItemIndexOffset, value.itemIndex)
         if (!read(blob, at + kSaleItemIndexOffset, value.itemIndex)
             || !read(blob, at + kSaleSecondaryItemOffset, value.secondaryItemIndex)
             || !read(blob, at + kSaleSecondaryItemOffset, value.secondaryItemIndex)
-            || !read(blob, at + kSaleCategoryIndexOffset, value.categoryIndex)
-            || !read_sale_cost(blob, at, value)) {
+            || !read(blob, at + kSaleCategoryIndexOffset, value.categoryIndex)) {
             return false;
             return false;
         }
         }
+        if (!read_sale_cost(blob, at, value)) {
+            ++storage.unreadableRows;
+            core::log::writef(core::log::Channel::state,
+                              core::log::Level::warn,
+                              "ev=build_data stage=vendors result=unreadable_cost hash=0x%08X "
+                              "row=%zu",
+                              definition.definitionHash,
+                              row);
+        } else if (value.priceState == domain::PriceState::conditional) {
+            ++storage.conditionalRows;
+        }
     }
     }
     storage.saleRowCount += definition.saleCount;
     storage.saleRowCount += definition.saleCount;
     return true;
     return true;
@@ -271,17 +311,20 @@ void report(const Storage& storage, std::size_t skipped, const char* result) noe
     const int written = std::snprintf(line.data(),
     const int written = std::snprintf(line.data(),
                                       line.size(),
                                       line.size(),
                                       "ev=build_data stage=vendors index=%zu definitions=%zu "
                                       "ev=build_data stage=vendors index=%zu definitions=%zu "
-                                      "sale=%zu installed=%zu skipped=%zu result=%s",
+                                      "sale=%zu conditional=%zu unreadable=%zu installed=%zu "
+                                      "skipped=%zu result=%s",
                                       storage.indexCount,
                                       storage.indexCount,
                                       storage.definitionCount,
                                       storage.definitionCount,
                                       storage.saleRowCount,
                                       storage.saleRowCount,
+                                      storage.conditionalRows,
+                                      storage.unreadableRows,
                                       storage.installedRowCount,
                                       storage.installedRowCount,
                                       skipped,
                                       skipped,
                                       result);
                                       result);
     if (written > 0) {
     if (written > 0) {
+        const bool clean = storage.indexCount != 0 && skipped == 0 && storage.unreadableRows == 0;
         core::log::write(core::log::Channel::state,
         core::log::write(core::log::Channel::state,
-                         storage.indexCount != 0 && skipped == 0 ? core::log::Level::info
-                                                                 : core::log::Level::warn,
+                         clean ? core::log::Level::info : core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
                          {line.data(), static_cast<std::size_t>(written)});
     }
     }
 }
 }

+ 69 - 54
Sunrise/src/server/web_service/web_service_vendor_actions.cpp

@@ -5,6 +5,7 @@
 #include <cstdio>
 #include <cstdio>
 #include <cstring>
 #include <cstring>
 #include <limits>
 #include <limits>
+#include <optional>
 #include <span>
 #include <span>
 #include <string_view>
 #include <string_view>
 
 
@@ -298,26 +299,33 @@ constexpr std::array<LegacyQuestStep, 3> kLegacyQuestSteps{{
     // A sale row holds its cost as u32; the mutation charges an i32, so a wider row is refused.
     // A sale row holds its cost as u32; the mutation charges an i32, so a wider row is refused.
     constexpr auto kQuantityLimit =
     constexpr auto kQuantityLimit =
         static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)());
         static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)());
+    // An exchange charges exactly one static stack, so a row that is not plainly priced, or
+    // declares any number of entries but one, is not one.
+    const std::span<const vendor_domain::SaleCost> price = vendor_domain::cost_entries(row);
+    const vendor_domain::SaleCost single = price.size() == 1 ? price[0] : vendor_domain::SaleCost{};
     state::build_data::items::Definition cost{};
     state::build_data::items::Definition cost{};
     // A recycle row owns its purchase from here, refused or not: falling through would grant the
     // A recycle row owns its purchase from here, refused or not: falling through would grant the
     // placeholder, which is the failure this path exists to avoid.
     // placeholder, which is the failure this path exists to avoid.
-    if (row.costItemIndex == vendor_domain::kAbsentCostItem || row.costQuantity == 0
-        || row.costQuantity > kQuantityLimit
-        || !state::build_data::find_item_definition_index(row.costItemIndex, cost)) {
+    if (row.priceState != vendor_domain::PriceState::plain || price.size() != 1
+        || single.quantity == 0 || single.quantity > kQuantityLimit
+        || !state::build_data::find_item_definition_index(single.itemIndex, cost)) {
         core::log::writef(core::log::Channel::server,
         core::log::writef(core::log::Channel::server,
                           core::log::Level::warn,
                           core::log::Level::warn,
                           "ev=vendor_exchange stage=apply result=fail reason=cost vendor=%d "
                           "ev=vendor_exchange stage=apply result=fail reason=cost vendor=%d "
-                          "hash=0x%08X row=%d cost_item=%u quantity=%u",
+                          "hash=0x%08X row=%d price_state=%u entries=%zu cost_item=%u "
+                          "quantity=%u",
                           vendorIndex,
                           vendorIndex,
                           entry.definitionHash,
                           entry.definitionHash,
                           rowIndex,
                           rowIndex,
-                          static_cast<unsigned>(row.costItemIndex),
-                          static_cast<unsigned>(row.costQuantity));
+                          static_cast<unsigned>(row.priceState),
+                          price.size(),
+                          static_cast<unsigned>(single.itemIndex),
+                          static_cast<unsigned>(single.quantity));
         return true;
         return true;
     }
     }
     const bool applied = state::prepare_vendor_exchange(
     const bool applied = state::prepare_vendor_exchange(
         cost.definitionHash,
         cost.definitionHash,
-        static_cast<std::int32_t>(row.costQuantity),
+        static_cast<std::int32_t>(single.quantity),
         std::span<const state::ProfileExchangePayout>{payouts.data(), payoutCount},
         std::span<const state::ProfileExchangePayout>{payouts.data(), payoutCount},
         mutation);
         mutation);
     core::log::writef(core::log::Channel::server,
     core::log::writef(core::log::Channel::server,
@@ -329,7 +337,7 @@ constexpr std::array<LegacyQuestStep, 3> kLegacyQuestSteps{{
                       entry.definitionHash,
                       entry.definitionHash,
                       rowIndex,
                       rowIndex,
                       cost.definitionHash,
                       cost.definitionHash,
-                      static_cast<unsigned>(row.costQuantity),
+                      static_cast<unsigned>(single.quantity),
                       payoutCount);
                       payoutCount);
     return true;
     return true;
 }
 }
@@ -340,13 +348,17 @@ constexpr std::array<LegacyQuestStep, 3> kLegacyQuestSteps{{
  * @param message Request being answered, for the log line.
  * @param message Request being answered, for the log line.
  * @param collectibleIndex Collectible that owns the item.
  * @param collectibleIndex Collectible that owns the item.
  * @param itemDefinitionIndex Item to grant.
  * @param itemDefinitionIndex Item to grant.
+ * @param price A vendor row's cost entries, spent in place of the collectible's material set;
+ *        absent for a Collections pull, which pays with the collectible's materials.
  * @param outcome Receives the prepared mutation on success.
  * @param outcome Receives the prepared mutation on success.
  * @return True when a mutation is prepared. A pursuit already held prepares none.
  * @return True when a mutation is prepared. A pursuit already held prepares none.
  */
  */
-bool grant_item_definition(const middleware::web_service::Message& message,
-                           std::uint16_t collectibleIndex,
-                           std::uint16_t itemDefinitionIndex,
-                           Outcome& outcome) noexcept {
+bool grant_item_definition(
+    const middleware::web_service::Message& message,
+    std::uint16_t collectibleIndex,
+    std::uint16_t itemDefinitionIndex,
+    std::optional<std::span<const state::build_data::vendors::SaleCost>> price,
+    Outcome& outcome) noexcept {
     state::build_data::items::Definition definition{};
     state::build_data::items::Definition definition{};
     if (!state::build_data::find_item_definition_index(itemDefinitionIndex, definition)) {
     if (!state::build_data::find_item_definition_index(itemDefinitionIndex, definition)) {
         report_item_acquisition(
         report_item_acquisition(
@@ -404,7 +416,7 @@ bool grant_item_definition(const middleware::web_service::Message& message,
             return false;
             return false;
         }
         }
         if (!state::prepare_profile_item_acquisition(
         if (!state::prepare_profile_item_acquisition(
-                collectibleIndex, definition.definitionHash, *mutation)) {
+                collectibleIndex, definition.definitionHash, price, *mutation)) {
             clear_mutation(outcome);
             clear_mutation(outcome);
             report_item_acquisition(message,
             report_item_acquisition(message,
                                     "profile_state",
                                     "profile_state",
@@ -436,7 +448,8 @@ bool grant_item_definition(const middleware::web_service::Message& message,
                                 0);
                                 0);
         return false;
         return false;
     }
     }
-    if (!state::prepare_item_acquisition(collectibleIndex, definition.definitionHash, *mutation)) {
+    if (!state::prepare_item_acquisition(
+            collectibleIndex, definition.definitionHash, price, *mutation)) {
         clear_mutation(outcome);
         clear_mutation(outcome);
         report_item_acquisition(
         report_item_acquisition(
             message, "state", collectibleIndex, itemDefinitionIndex, definition.definitionHash, 0);
             message, "state", collectibleIndex, itemDefinitionIndex, definition.definitionHash, 0);
@@ -478,24 +491,24 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
             message, "collectible_definition", collectibleIndex, kUnavailableDefinitionIndex, 0, 0);
             message, "collectible_definition", collectibleIndex, kUnavailableDefinitionIndex, 0, 0);
         return;
         return;
     }
     }
-    (void)grant_item_definition(message, collectibleIndex, itemDefinitionIndex, outcome);
+    (void)grant_item_definition(
+        message, collectibleIndex, itemDefinitionIndex, std::nullopt, outcome);
 }
 }
 
 
 /**
 /**
  * Resolves one vendor row to the item it sells. Shared by 901 and 904, which name a row alike.
  * Resolves one vendor row to the item it sells. Shared by 901 and 904, which name a row alike.
  * @param vendorIndex Vendor table row.
  * @param vendorIndex Vendor table row.
  * @param rowIndex Sale row within that vendor.
  * @param rowIndex Sale row within that vendor.
- * @param itemDefinitionIndex Receives the item the row sells.
- * @param categoryIndex Receives the row's category, which is its sale row plus 100.
+ * @param row Receives the sale row: its item, category and price.
  * @param reason Receives the step that failed, when one does.
  * @param reason Receives the step that failed, when one does.
  * @return True when the row resolved.
  * @return True when the row resolved.
  */
  */
 [[nodiscard]] bool resolve_vendor_row(std::int32_t vendorIndex,
 [[nodiscard]] bool resolve_vendor_row(std::int32_t vendorIndex,
                                       std::int32_t rowIndex,
                                       std::int32_t rowIndex,
-                                      std::uint16_t& itemDefinitionIndex,
-                                      std::int32_t& categoryIndex,
+                                      state::build_data::vendors::SaleRow& row,
                                       const char*& reason) noexcept {
                                       const char*& reason) noexcept {
     namespace vendor_domain = state::build_data::vendors;
     namespace vendor_domain = state::build_data::vendors;
+    row = {};
     if (vendorIndex < 0 || rowIndex < 0) {
     if (vendorIndex < 0 || rowIndex < 0) {
         reason = "negative_index";
         reason = "negative_index";
         return false;
         return false;
@@ -506,13 +519,10 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
         reason = "vendor";
         reason = "vendor";
         return false;
         return false;
     }
     }
-    vendor_domain::SaleRow row{};
     if (!vendor_domain::sale_row(definition, static_cast<std::size_t>(rowIndex), row)) {
     if (!vendor_domain::sale_row(definition, static_cast<std::size_t>(rowIndex), row)) {
         reason = "sale_row";
         reason = "sale_row";
         return false;
         return false;
     }
     }
-    itemDefinitionIndex = row.itemIndex;
-    categoryIndex = row.categoryIndex;
     return true;
     return true;
 }
 }
 
 
@@ -634,17 +644,33 @@ constexpr std::uint32_t kAbsentNameHash = 0x811C9DC5U;
  * @param opcode Opcode to report under.
  * @param opcode Opcode to report under.
  * @param vendorIndex Vendor the request names.
  * @param vendorIndex Vendor the request names.
  * @param rowIndex Sale row the request names.
  * @param rowIndex Sale row the request names.
- * @param categoryIndex Category of that row, from sale row +100.
- * @param itemDefinitionIndex Item the row names.
+ * @param row The resolved sale row. Its price is spent by a grant and by a bounty roll, and an
+ *        exchange spends it as the stack it recycles.
  * @param outcome Receives whatever mutation the row prepared.
  * @param outcome Receives whatever mutation the row prepared.
  */
  */
 void settle_vendor_row(const middleware::web_service::Message& message,
 void settle_vendor_row(const middleware::web_service::Message& message,
                        std::uint16_t opcode,
                        std::uint16_t opcode,
                        std::int32_t vendorIndex,
                        std::int32_t vendorIndex,
                        std::int32_t rowIndex,
                        std::int32_t rowIndex,
-                       std::int32_t categoryIndex,
-                       std::uint16_t itemDefinitionIndex,
+                       const state::build_data::vendors::SaleRow& row,
                        Outcome& outcome) noexcept {
                        Outcome& outcome) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    const std::int32_t categoryIndex = row.categoryIndex;
+    const std::uint16_t itemDefinitionIndex = row.itemIndex;
+    // A row is sold only while its static entries are its price. A conditional row's price
+    // depends on state this build does not evaluate, and an unreadable row's is unknown.
+    if (row.priceState != vendor_domain::PriceState::plain) {
+        report_purchase(opcode,
+                        "fail",
+                        row.priceState == vendor_domain::PriceState::conditional
+                            ? "conditional_price"
+                            : "unreadable_price",
+                        vendorIndex,
+                        rowIndex,
+                        itemDefinitionIndex);
+        return;
+    }
+    const std::span<const vendor_domain::SaleCost> price = vendor_domain::cost_entries(row);
     std::uint16_t rolledBounty = kUnavailableDefinitionIndex;
     std::uint16_t rolledBounty = kUnavailableDefinitionIndex;
     if (roll_vendor_bounty(vendorIndex, categoryIndex, rolledBounty)) {
     if (roll_vendor_bounty(vendorIndex, categoryIndex, rolledBounty)) {
         report_purchase(opcode,
         report_purchase(opcode,
@@ -657,7 +683,9 @@ void settle_vendor_row(const middleware::web_service::Message& message,
         if (rolledBounty != kUnavailableDefinitionIndex) {
         if (rolledBounty != kUnavailableDefinitionIndex) {
             std::uint16_t rolledCollectible = state::build_data::collectibles::kNoCollectibleIndex;
             std::uint16_t rolledCollectible = state::build_data::collectibles::kNoCollectibleIndex;
             (void)find_collectible_for_item(rolledBounty, rolledCollectible);
             (void)find_collectible_for_item(rolledBounty, rolledCollectible);
-            (void)grant_item_definition(message, rolledCollectible, rolledBounty, outcome);
+            // The tile prices whatever it rolls: only its placeholder item is withheld, never
+            // its price. A tile the character cannot pay for grants nothing.
+            (void)grant_item_definition(message, rolledCollectible, rolledBounty, price, outcome);
         }
         }
         return;
         return;
     }
     }
@@ -696,7 +724,7 @@ void settle_vendor_row(const middleware::web_service::Message& message,
                     vendorIndex,
                     vendorIndex,
                     rowIndex,
                     rowIndex,
                     granted);
                     granted);
-    (void)grant_item_definition(message, collectibleIndex, granted, outcome);
+    (void)grant_item_definition(message, collectibleIndex, granted, price, outcome);
 }
 }
 
 
 /**
 /**
@@ -722,17 +750,17 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out
         return;
         return;
     }
     }
     const std::int32_t row = request.saleIndex;
     const std::int32_t row = request.saleIndex;
-    std::uint16_t itemDefinitionIndex = 0;
     const char* reason = "unknown";
     const char* reason = "unknown";
     // A row of -1 says the tile is not a sale row at all, so the installed array answers it.
     // A row of -1 says the tile is not a sale row at all, so the installed array answers it.
     // Reading the slot as a sale row here would grant whatever sits at that row.
     // Reading the slot as a sale row here would grant whatever sits at that row.
     const bool rowless = row < 0;
     const bool rowless = row < 0;
-    // A rowless 904 is an interaction reply, so its slot names the interaction, not a sale row.
-    std::int32_t questCategoryIndex = -1;
+    // A rowless 904 is an interaction reply, so its slot names the interaction, not a sale row:
+    // it has no category and no price.
+    state::build_data::vendors::SaleRow sale{};
+    sale.categoryIndex = state::build_data::vendors::kAbsentCategoryIndex;
     const bool located =
     const bool located =
-        rowless ? resolve_rowless_quest(request.vendorIndex, request.slotIndex, itemDefinitionIndex)
-                : resolve_vendor_row(
-                      request.vendorIndex, row, itemDefinitionIndex, questCategoryIndex, reason);
+        rowless ? resolve_rowless_quest(request.vendorIndex, request.slotIndex, sale.itemIndex)
+                : resolve_vendor_row(request.vendorIndex, row, sale, reason);
     if (!located) {
     if (!located) {
         report_purchase(quest::kOpcode,
         report_purchase(quest::kOpcode,
                         "fail",
                         "fail",
@@ -746,19 +774,13 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out
         }
         }
         return;
         return;
     }
     }
-    settle_vendor_row(message,
-                      quest::kOpcode,
-                      request.vendorIndex,
-                      row,
-                      questCategoryIndex,
-                      itemDefinitionIndex,
-                      outcome);
+    settle_vendor_row(message, quest::kOpcode, request.vendorIndex, row, sale, outcome);
 }
 }
 
 
 /**
 /**
  * Prepares one opcode-901 vendor purchase, for any Tower vendor.
  * Prepares one opcode-901 vendor purchase, for any Tower vendor.
- * The sale row names an item-definition index, so this hands over to the Collections grant.
- * Only a recycle row charges: an ordinary row's cost is read but not yet spent.
+ * The sale row names an item-definition index, so this hands over to the Collections grant, and
+ * the row's own cost is charged with it: a purchase that cannot pay grants nothing.
  */
  */
 void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
 void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
     namespace purchase = middleware::web_service::messages::opcode901;
     namespace purchase = middleware::web_service::messages::opcode901;
@@ -767,11 +789,9 @@ void purchase_item(const middleware::web_service::Message& message, Outcome& out
         report_purchase(purchase::kOpcode, "fail", "payload", -1, -1, kUnavailableDefinitionIndex);
         report_purchase(purchase::kOpcode, "fail", "payload", -1, -1, kUnavailableDefinitionIndex);
         return;
         return;
     }
     }
-    std::uint16_t itemDefinitionIndex = 0;
     const char* reason = "unknown";
     const char* reason = "unknown";
-    std::int32_t categoryIndex = -1;
-    if (!resolve_vendor_row(
-            request.vendorIndex, request.saleIndex, itemDefinitionIndex, categoryIndex, reason)) {
+    state::build_data::vendors::SaleRow sale{};
+    if (!resolve_vendor_row(request.vendorIndex, request.saleIndex, sale, reason)) {
         report_purchase(purchase::kOpcode,
         report_purchase(purchase::kOpcode,
                         "fail",
                         "fail",
                         reason,
                         reason,
@@ -780,13 +800,8 @@ void purchase_item(const middleware::web_service::Message& message, Outcome& out
                         kUnavailableDefinitionIndex);
                         kUnavailableDefinitionIndex);
         return;
         return;
     }
     }
-    settle_vendor_row(message,
-                      purchase::kOpcode,
-                      request.vendorIndex,
-                      request.saleIndex,
-                      categoryIndex,
-                      itemDefinitionIndex,
-                      outcome);
+    settle_vendor_row(
+        message, purchase::kOpcode, request.vendorIndex, request.saleIndex, sale, outcome);
 }
 }
 
 
 } // namespace sunrise::server::web_service
 } // namespace sunrise::server::web_service

+ 27 - 6
Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp

@@ -74,28 +74,49 @@ bool decode(const VendorDefinitionRecord& record, vendors::Definition& value) no
     return true;
     return true;
 }
 }
 
 
-/** Encodes one vendor sale row. */
+/** Encodes one vendor sale row. Unused cost entries stay zero so the packed row always matches. */
 bool encode(const vendors::SaleRow& value, VendorSaleRowRecord& record) noexcept {
 bool encode(const vendors::SaleRow& value, VendorSaleRowRecord& record) noexcept {
     record = {};
     record = {};
+    if (value.costCount > value.costs.size()) {
+        return false;
+    }
     record.itemIndex = value.itemIndex;
     record.itemIndex = value.itemIndex;
     record.secondaryItemIndex = value.secondaryItemIndex;
     record.secondaryItemIndex = value.secondaryItemIndex;
     record.categoryIndex = value.categoryIndex;
     record.categoryIndex = value.categoryIndex;
-    record.costQuantity = value.costQuantity;
-    record.costItemIndex = value.costItemIndex;
+    record.costCount = value.costCount;
+    record.priceState = static_cast<std::uint8_t>(value.priceState);
+    for (std::size_t cost = 0; cost < value.costCount; ++cost) {
+        record.costs[cost].itemIndex = value.costs[cost].itemIndex;
+        record.costs[cost].quantity = value.costs[cost].quantity;
+    }
     return true;
     return true;
 }
 }
 
 
 /** Decodes one vendor sale row. */
 /** Decodes one vendor sale row. */
 bool decode(const VendorSaleRowRecord& record, vendors::SaleRow& value) noexcept {
 bool decode(const VendorSaleRowRecord& record, vendors::SaleRow& value) noexcept {
     value = {};
     value = {};
-    if (record.reserved != decltype(record.reserved){}) {
+    if (record.reserved != decltype(record.reserved){} || record.costCount > record.costs.size()
+        || record.priceState > static_cast<std::uint8_t>(vendors::PriceState::unreadable)) {
         return false;
         return false;
     }
     }
+    for (std::size_t cost = 0; cost < record.costs.size(); ++cost) {
+        const VendorSaleCostRecord& stored = record.costs[cost];
+        if (stored.reserved != 0) {
+            return false;
+        }
+        if (cost >= record.costCount) {
+            if (stored.itemIndex != 0 || stored.quantity != 0) {
+                return false;
+            }
+            continue;
+        }
+        value.costs[cost] = {stored.itemIndex, stored.quantity};
+    }
     value.itemIndex = record.itemIndex;
     value.itemIndex = record.itemIndex;
     value.secondaryItemIndex = record.secondaryItemIndex;
     value.secondaryItemIndex = record.secondaryItemIndex;
     value.categoryIndex = record.categoryIndex;
     value.categoryIndex = record.categoryIndex;
-    value.costQuantity = record.costQuantity;
-    value.costItemIndex = record.costItemIndex;
+    value.costCount = record.costCount;
+    value.priceState = static_cast<vendors::PriceState>(record.priceState);
     return true;
     return true;
 }
 }
 
 

+ 18 - 5
Sunrise/src/state/build_data/cache/records/format.h

@@ -31,7 +31,7 @@ namespace sunrise::state::build_data::cache::records {
 /** These 8 ASCII bytes mark a Sunrise build-data file. */
 /** These 8 ASCII bytes mark a Sunrise build-data file. */
 inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'};
 inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'};
 /** Bump when stored layouts or extracted values change; other versions are rebuilt. */
 /** Bump when stored layouts or extracted values change; other versions are rebuilt. */
-inline constexpr std::uint32_t kCacheFormatVersion = 65;
+inline constexpr std::uint32_t kCacheFormatVersion = 66;
 /** Signed -1 on disk means there is no equipment slot. */
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -546,15 +546,25 @@ struct VendorDefinitionRecord {
     std::uint16_t thirdCount{};
     std::uint16_t thirdCount{};
 };
 };
 
 
+/** Disk form of one cost entry of a vendor sale row. */
+struct VendorSaleCostRecord {
+    std::uint32_t quantity{};
+    std::uint16_t itemIndex{};
+    /** Must be zero, so the packed cost entry always matches. */
+    std::uint16_t reserved{};
+};
+
 /** Disk form of one vendor sale row. */
 /** Disk form of one vendor sale row. */
 struct VendorSaleRowRecord {
 struct VendorSaleRowRecord {
     std::int32_t categoryIndex{};
     std::int32_t categoryIndex{};
-    std::uint32_t costQuantity{};
+    std::array<VendorSaleCostRecord, vendors::kSaleCostCapacity> costs{};
     std::uint16_t itemIndex{};
     std::uint16_t itemIndex{};
     std::uint16_t secondaryItemIndex{};
     std::uint16_t secondaryItemIndex{};
-    std::uint16_t costItemIndex{};
+    std::uint8_t costCount{};
+    /** `vendors::PriceState`, stored as its underlying value. */
+    std::uint8_t priceState{};
     /** Must be zero, so the packed sale row always matches. */
     /** Must be zero, so the packed sale row always matches. */
-    std::uint16_t reserved{};
+    std::array<std::uint8_t, 2> reserved{};
 };
 };
 
 
 /** Disk form of one vendor category row. */
 /** Disk form of one vendor category row. */
@@ -588,7 +598,10 @@ static_assert(sizeof(SpawnPointRecord)
 static_assert(sizeof(VendorIndexRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(VendorIndexRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(VendorDefinitionRecord)
 static_assert(sizeof(VendorDefinitionRecord)
               == 14 * sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t));
               == 14 * sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t));
-static_assert(sizeof(VendorSaleRowRecord) == 4 * sizeof(std::uint16_t) + 2 * sizeof(std::uint32_t));
+static_assert(sizeof(VendorSaleCostRecord) == sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t));
+static_assert(sizeof(VendorSaleRowRecord)
+              == sizeof(std::int32_t) + vendors::kSaleCostCapacity * sizeof(VendorSaleCostRecord)
+                     + 2 * sizeof(std::uint16_t) + 4 * sizeof(std::uint8_t));
 static_assert(sizeof(VendorInstalledRowRecord) == sizeof(std::uint32_t));
 static_assert(sizeof(VendorInstalledRowRecord) == sizeof(std::uint32_t));
 static_assert(sizeof(HashNameRecord)
 static_assert(sizeof(HashNameRecord)
               == hash_names::kNameLength + sizeof(std::uint32_t) + 4 * sizeof(std::uint8_t));
               == hash_names::kNameLength + sizeof(std::uint32_t) + 4 * sizeof(std::uint8_t));

+ 52 - 7
Sunrise/src/state/build_data/vendors/definition.h

@@ -1,7 +1,9 @@
 #pragma once
 #pragma once
 
 
+#include <array>
 #include <cstddef>
 #include <cstddef>
 #include <cstdint>
 #include <cstdint>
+#include <span>
 
 
 namespace sunrise::state::build_data::vendors {
 namespace sunrise::state::build_data::vendors {
 
 
@@ -22,9 +24,9 @@ inline constexpr std::size_t kSaleRowStride = 184;
 inline constexpr std::size_t kInstalledRowStride = 24;
 inline constexpr std::size_t kInstalledRowStride = 24;
 /** One interaction row is 80 bytes. */
 /** One interaction row is 80 bytes. */
 inline constexpr std::size_t kThirdRowStride = 80;
 inline constexpr std::size_t kThirdRowStride = 80;
-/** One price-override row is 48 bytes: the cost item index, then the units it charges. */
+/** One cost entry is 48 bytes. `SaleCost` documents its layout. */
 inline constexpr std::size_t kSaleCostRowStride = 48;
 inline constexpr std::size_t kSaleCostRowStride = 48;
-/** Element class of a sale row's price-override array. */
+/** Element class of a sale row's cost array. */
 inline constexpr std::uint32_t kSaleCostRowClass = 0x80807865U;
 inline constexpr std::uint32_t kSaleCostRowClass = 0x80807865U;
 
 
 /** Wrapper class of the vendor index blob. */
 /** Wrapper class of the vendor index blob. */
@@ -83,23 +85,66 @@ struct Definition {
     std::uint16_t thirdCount{};
     std::uint16_t thirdCount{};
 };
 };
 
 
-/** A sale row charging nothing carries this instead of a cost item. */
+/** A cost entry naming no item carries this. */
 inline constexpr std::uint16_t kAbsentCostItem = 0xFFFFU;
 inline constexpr std::uint16_t kAbsentCostItem = 0xFFFFU;
+/** Cost entries one sale row may declare. The widest row in the installed catalog declares four. */
+inline constexpr std::size_t kSaleCostCapacity = 4;
+/**
+ * Cost entry +40 in every expression-free entry of the installed catalog. Its role is not
+ * decoded, so an entry carrying anything else is conditional rather than priced statically.
+ */
+inline constexpr std::uint32_t kPlainCostWord = 100'000U;
+
+/**
+ * One cost entry of a sale row (row +32 array, `kSaleCostRowClass`, 48 bytes), reduced to its
+ * static item and quantity. The entry also carries two expression arrays, at +8 and +24, and a
+ * word at +40; those decide whether the static quantity is the price at all, and the answer is
+ * kept on the row as its `PriceState`. On Xûr's definition every entry is static: item 128 with
+ * 29, 23, 97 and 9 units, the Legendary Shard prices of his weapons, armour, Fated Engram and
+ * Invitation of the Nine.
+ */
+struct SaleCost {
+    /** Entry +0. Cost item-definition index. */
+    std::uint16_t itemIndex{kAbsentCostItem};
+    /** Entry +4. Units charged. */
+    std::uint32_t quantity{};
+};
+
+/** Whether a sale row's static cost entries are its price. */
+enum class PriceState : std::uint8_t {
+    /** Every entry is expression-free with the plain word: the static quantities are the price. */
+    plain,
+    /**
+     * An entry carries an expression, or a word other than `kPlainCostWord`. Its price depends
+     * on state this build does not evaluate, so the row cannot be bought.
+     */
+    conditional,
+    /** The row's cost array did not read. The row keeps its item and category and cannot be bought.
+     */
+    unreadable,
+};
 
 
 /** One sale row of one vendor definition. */
 /** One sale row of one vendor definition. */
 struct SaleRow {
 struct SaleRow {
     /** Row +100. The row's vendor category. The catalog bounds it by the category count. */
     /** Row +100. The row's vendor category. The catalog bounds it by the category count. */
     std::int32_t categoryIndex{};
     std::int32_t categoryIndex{};
-    /** First price-override row's charged units. Zero when the row charges nothing. */
-    std::uint32_t costQuantity{};
     /** Row +70. Main sale item-definition index. */
     /** Row +70. Main sale item-definition index. */
     std::uint16_t itemIndex{};
     std::uint16_t itemIndex{};
     /** Row +176. `kAbsentSecondaryItem` when the row names none. */
     /** Row +176. `kAbsentSecondaryItem` when the row names none. */
     std::uint16_t secondaryItemIndex{};
     std::uint16_t secondaryItemIndex{};
-    /** First price-override row's item, or `kAbsentCostItem` when the row charges nothing. */
-    std::uint16_t costItemIndex{kAbsentCostItem};
+    /** Every cost entry, in declared order. A plain row charges all of them together. */
+    std::array<SaleCost, kSaleCostCapacity> costs{};
+    /** Entries of `costs` in use. Zero when the row charges nothing. */
+    std::uint8_t costCount{};
+    /** Whether `costs` is the price. Only a plain row is charged. */
+    PriceState priceState{PriceState::plain};
 };
 };
 
 
+/** @return The static cost entries of one sale row, which are its price only while it is plain. */
+[[nodiscard]] inline std::span<const SaleCost> cost_entries(const SaleRow& row) noexcept {
+    return {row.costs.data(), row.costCount > row.costs.size() ? std::size_t{0} : row.costCount};
+}
+
 /** One category row, reduced to the definition hash a rowless request resolves through. */
 /** One category row, reduced to the definition hash a rowless request resolves through. */
 struct InstalledRow {
 struct InstalledRow {
     std::uint32_t definitionHash{};
     std::uint32_t definitionHash{};

+ 7 - 2
Sunrise/src/state/build_data/vendors/vendor_catalog.cpp

@@ -84,7 +84,8 @@ Table<InstalledRow, kInstalledRowCapacity> g_installedRows;
  * Checks the sale rows one definition owns.
  * Checks the sale rows one definition owns.
  * @param definition Owning definition.
  * @param definition Owning definition.
  * @param saleRows Complete flat sale bank.
  * @param saleRows Complete flat sale bank.
- * @return True when every row names a category of this definition, or none at all.
+ * @return True when every row names a category of this definition, or none at all, and its cost
+ *         entries fit their state: an unreadable row has none.
  */
  */
 [[nodiscard]] bool canonical_sale_rows(const Definition& definition,
 [[nodiscard]] bool canonical_sale_rows(const Definition& definition,
                                        std::span<const SaleRow> saleRows) noexcept {
                                        std::span<const SaleRow> saleRows) noexcept {
@@ -94,7 +95,11 @@ Table<InstalledRow, kInstalledRowCapacity> g_installedRows;
         const bool selects =
         const bool selects =
             value.categoryIndex == kAbsentCategoryIndex
             value.categoryIndex == kAbsentCategoryIndex
             || (value.categoryIndex >= 0 && value.categoryIndex < definition.installedCount);
             || (value.categoryIndex >= 0 && value.categoryIndex < definition.installedCount);
-        if (!selects) {
+        const bool priced =
+            value.costCount <= value.costs.size()
+            && (value.priceState == PriceState::plain || value.priceState == PriceState::conditional
+                || (value.priceState == PriceState::unreadable && value.costCount == 0));
+        if (!selects || !priced) {
             return false;
             return false;
         }
         }
     }
     }

+ 18 - 9
Sunrise/src/state/runtime/runtime.h

@@ -3,11 +3,13 @@
 #include <array>
 #include <array>
 #include <cstddef>
 #include <cstddef>
 #include <cstdint>
 #include <cstdint>
+#include <optional>
 #include <span>
 #include <span>
 #include <variant>
 #include <variant>
 
 
 #include "../build_data/items/quest_initialization.h"
 #include "../build_data/items/quest_initialization.h"
 #include "../build_data/records/definition.h"
 #include "../build_data/records/definition.h"
+#include "../build_data/vendors/definition.h"
 #include "state.h"
 #include "state.h"
 
 
 namespace sunrise::state::account::settings {
 namespace sunrise::state::account::settings {
@@ -520,14 +522,18 @@ set_selected_title(std::uint16_t recordIndex, std::uint64_t& characterSoid, bool
  * Native-default sockets, a unique runtime SOID, and the selected character's current item level
  * Native-default sockets, a unique runtime SOID, and the selected character's current item level
  * are used. Full loadout resolution is the authoritative bucket-capacity check.
  * are used. Full loadout resolution is the authoritative bucket-capacity check.
  *
  *
- * @param collectibleIndex Collections row the Client pulled from.
+ * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
  * @param definitionHash Installed item definition requested by the Client.
  * @param definitionHash Installed item definition requested by the Client.
+ * @param price A vendor sale row's cost entries, spent in place of the collectible's material
+ *        set; absent for a Collections pull, which pays with the collectible's materials.
  * @param mutation Gets a checked after-image without changing account State.
  * @param mutation Gets a checked after-image without changing account State.
  * @return True when the item and every existing loadout row resolve with one free native row.
  * @return True when the item and every existing loadout row resolve with one free native row.
  */
  */
-[[nodiscard]] bool prepare_item_acquisition(std::uint16_t collectibleIndex,
-                                            std::uint32_t definitionHash,
-                                            PendingItemAcquisition& mutation) noexcept;
+[[nodiscard]] bool
+prepare_item_acquisition(std::uint16_t collectibleIndex,
+                         std::uint32_t definitionHash,
+                         std::optional<std::span<const build_data::vendors::SaleCost>> price,
+                         PendingItemAcquisition& mutation) noexcept;
 
 
 /** Prepares a direct character-item grant without a Collections charge. */
 /** Prepares a direct character-item grant without a Collections charge. */
 [[nodiscard]] bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
 [[nodiscard]] bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
@@ -591,15 +597,18 @@ reserve_selected_character_inventory_serial(std::int32_t& mutationSerial) noexce
  * An existing non-full stack is incremented. Otherwise a new dense State entry is appended only
  * An existing non-full stack is incremented. Otherwise a new dense State entry is appended only
  * when the installed profile bucket still owns a free native row.
  * when the installed profile bucket still owns a free native row.
  *
  *
- * @param collectibleIndex Collections row the Client pulled from.
+ * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
  * @param definitionHash Installed stackable definition requested by the Client.
  * @param definitionHash Installed stackable definition requested by the Client.
+ * @param price A vendor sale row's cost entries, spent in place of the collectible's material
+ *        set; absent for a Collections pull, which pays with the collectible's materials.
  * @param mutation Gets the checked profile before/after images without changing account State.
  * @param mutation Gets the checked profile before/after images without changing account State.
  * @return True when the definition belongs to the main profile array and one unit fits.
  * @return True when the definition belongs to the main profile array and one unit fits.
  */
  */
-[[nodiscard]] bool
-prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
-                                 std::uint32_t definitionHash,
-                                 PendingProfileItemAcquisition& mutation) noexcept;
+[[nodiscard]] bool prepare_profile_item_acquisition(
+    std::uint16_t collectibleIndex,
+    std::uint32_t definitionHash,
+    std::optional<std::span<const build_data::vendors::SaleCost>> price,
+    PendingProfileItemAcquisition& mutation) noexcept;
 
 
 /** Prepares a direct profile-stack grant without a Collections charge. */
 /** Prepares a direct profile-stack grant without a Collections charge. */
 [[nodiscard]] bool
 [[nodiscard]] bool

+ 34 - 14
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -165,18 +165,20 @@ using Quest = build_data::items::QuestInitialization;
  * Inventory and quest state must come from the same locked save view.
  * Inventory and quest state must come from the same locked save view.
  * @param collectibleIndex Collections row, or kNoCollectibleIndex for an item-only grant.
  * @param collectibleIndex Collections row, or kNoCollectibleIndex for an item-only grant.
  * @param definitionHash Item definition to grant.
  * @param definitionHash Item definition to grant.
+ * @param price A vendor row's cost, spent in place of the collectible's materials; or absent.
  * @param mutation Receives a pending grant; prepared is set only on success.
  * @param mutation Receives a pending grant; prepared is set only on success.
  * @return False when identity, costs, capacity, or saved state prevent the grant.
  * @return False when identity, costs, capacity, or saved state prevent the grant.
  */
  */
 bool prepare_item_acquisition(std::uint16_t collectibleIndex,
 bool prepare_item_acquisition(std::uint16_t collectibleIndex,
                               std::uint32_t definitionHash,
                               std::uint32_t definitionHash,
+                              std::optional<std::span<const build_data::vendors::SaleCost>> price,
                               PendingItemAcquisition& mutation) noexcept {
                               PendingItemAcquisition& mutation) noexcept {
     const std::lock_guard lock(investment::store::g_mutex);
     const std::lock_guard lock(investment::store::g_mutex);
     mutation = {};
     mutation = {};
     const AccountState account = account_snapshot();
     const AccountState account = account_snapshot();
     build_data::collectibles::Definition collectible{};
     build_data::collectibles::Definition collectible{};
     build_data::items::Definition grantedDefinition{};
     build_data::items::Definition grantedDefinition{};
-    // A vendor purchase names an item, not a collectible, so the collectible steps are skipped
+    // A vendor row may name an item with no collectible, so the collectible steps are skipped
     // rather than faked. The item is still validated, just by its own hash.
     // rather than faked. The item is still validated, just by its own hash.
     const bool hasCollectible = collectibleIndex != build_data::collectibles::kNoCollectibleIndex;
     const bool hasCollectible = collectibleIndex != build_data::collectibles::kNoCollectibleIndex;
     if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
     if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
@@ -199,13 +201,19 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
 
 
     AccountState chargedAccount = account;
     AccountState chargedAccount = account;
     bool profileChanged = false;
     bool profileChanged = false;
-    // Nothing is charged without a collectible: the cost lives on the collectible's material
-    // requirements, and a sale row's own cost fields are still role-open.
-    if (hasCollectible
-        && !apply_collection_materials(account, collectible, chargedAccount, profileChanged)) {
+    // A vendor row pays its own price, whatever its collectible would charge from Collections;
+    // a Collections pull pays with the collectible's materials, and an item-only grant is free.
+    const bool paid = price.has_value()
+                          ? apply_sale_price(account, *price, chargedAccount, profileChanged)
+                          : !hasCollectible
+                                || apply_collection_materials(
+                                    account, collectible, chargedAccount, profileChanged);
+    if (!paid) {
         return false;
         return false;
     }
     }
 
 
+    // Commit re-checks the collectible's own cost fields, so the mutation carries those; a sale
+    // price is proven only by its before/after profile images.
     return finalize_item_acquisition(
     return finalize_item_acquisition(
         account,
         account,
         chargedAccount,
         chargedAccount,
@@ -711,10 +719,15 @@ finalize_profile_item_acquisition(const AccountState& account,
 
 
 } // namespace runtime::detail
 } // namespace runtime::detail
 
 
-/** Prepares one checked profile-stack increment or append for a Collections pull. */
-bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
-                                      std::uint32_t definitionHash,
-                                      PendingProfileItemAcquisition& mutation) noexcept {
+/**
+ * Prepares one checked profile-stack increment or append.
+ * @param price A vendor row's cost, spent in place of the collectible's materials; or absent.
+ */
+bool prepare_profile_item_acquisition(
+    std::uint16_t collectibleIndex,
+    std::uint32_t definitionHash,
+    std::optional<std::span<const build_data::vendors::SaleCost>> price,
+    PendingProfileItemAcquisition& mutation) noexcept {
     mutation = {};
     mutation = {};
     const AccountState account = account_snapshot();
     const AccountState account = account_snapshot();
     build_data::collectibles::Definition collectible{};
     build_data::collectibles::Definition collectible{};
@@ -736,16 +749,22 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
     }
     }
     AccountState chargedAccount = account;
     AccountState chargedAccount = account;
     bool materialsChanged = false;
     bool materialsChanged = false;
-    // Nothing is charged without a collectible: the cost lives on the collectible's material
-    // requirements, and a sale row's own cost fields are still role-open.
-    if (collectibleIndex != build_data::collectibles::kNoCollectibleIndex
-        && !apply_collection_materials(account, collectible, chargedAccount, materialsChanged)) {
+    // A vendor row pays its own price, whatever its collectible would charge from Collections;
+    // a Collections pull pays with the collectible's materials, and an item-only grant is free.
+    const bool paid = price.has_value()
+                          ? apply_sale_price(account, *price, chargedAccount, materialsChanged)
+                          : collectibleIndex == build_data::collectibles::kNoCollectibleIndex
+                                || apply_collection_materials(
+                                    account, collectible, chargedAccount, materialsChanged);
+    if (!paid) {
         return false;
         return false;
     }
     }
     (void)materialsChanged;
     (void)materialsChanged;
     const bool actionSource =
     const bool actionSource =
         build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
         build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
 
 
+    // Commit re-checks the collectible's own cost fields, so the mutation carries those; a sale
+    // price is proven only by its before/after profile images.
     return finalize_profile_item_acquisition(
     return finalize_profile_item_acquisition(
         account,
         account,
         chargedAccount,
         chargedAccount,
@@ -792,7 +811,8 @@ bool preview_profile_item_acquisition(const PendingProfileItemAcquisition& mutat
     return materialize_profile_acquisition(current, mutation, after);
     return materialize_profile_acquisition(current, mutation, after);
 }
 }
 
 
-/** Commits one profile-stack after-image only while its exact prepare-time view remains current. */
+/** Commits one profile-stack after-image only while its exact prepare-time view remains
+ * current. */
 bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcept {
 bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcept {
     const PendingProfileItemAcquisition& prepared = mutation;
     const PendingProfileItemAcquisition& prepared = mutation;
     const PendingConsumption consume{mutation};
     const PendingConsumption consume{mutation};

+ 65 - 0
Sunrise/src/state/runtime/state_account_profile_runtime.cpp

@@ -9,6 +9,7 @@
 #include <string_view>
 #include <string_view>
 #include <utility>
 #include <utility>
 
 
+#include "../../core/logging/log.h"
 #include "../build_data/runtime.h"
 #include "../build_data/runtime.h"
 #include "runtime.h"
 #include "runtime.h"
 #include "state_account_transaction_helpers.h"
 #include "state_account_transaction_helpers.h"
@@ -294,6 +295,70 @@ apply_collection_materials(const AccountState& before,
         changed);
         changed);
 }
 }
 
 
+/**
+ * Writes what a refused price asked for, entry by entry, against what the profile holds.
+ * A held count of -1 names an entry whose item is not installed, which the engine cannot debit.
+ */
+static void report_price_refusal(const AccountState& account,
+                                 std::span<const build_data::vendors::SaleCost> price) noexcept {
+    for (const build_data::vendors::SaleCost& entry : price) {
+        build_data::items::Definition item{};
+        std::int64_t held = -1;
+        if (build_data::find_item_definition_index(entry.itemIndex, item)) {
+            held = 0;
+            for (std::size_t index = 0; index < account.profileItemCount; ++index) {
+                if (account.profileItems[index].definitionHash == item.definitionHash) {
+                    held += account.profileItems[index].quantity;
+                }
+            }
+        }
+        core::log::writef(core::log::Channel::state,
+                          core::log::Level::warn,
+                          "ev=vendor stage=price result=fail item=%u quantity=%u held=%lld",
+                          static_cast<unsigned>(entry.itemIndex),
+                          entry.quantity,
+                          static_cast<long long>(held));
+    }
+}
+
+/**
+ * Spends one vendor sale row's price through the engine that spends Collections materials, so a
+ * cost item is validated and debited by the same rules: a stackable profile item, every stack
+ * summed, refused when short, then debited and compacted.
+ * @param price The row's cost entries; empty charges nothing.
+ * @param after Receives the charged account, or `before` again when refused.
+ * @return True when every entry was paid, or the price was empty.
+ */
+[[nodiscard]] bool apply_sale_price(const AccountState& before,
+                                    std::span<const build_data::vendors::SaleCost> price,
+                                    AccountState& after,
+                                    bool& changed) noexcept {
+    namespace materials = build_data::material_requirements;
+    after = before;
+    changed = false;
+    if (price.size() > build_data::vendors::kSaleCostCapacity) {
+        return false;
+    }
+    // Every entry is consumed on purchase; nothing in a sale row is a held-only requirement.
+    std::array<materials::Requirement, build_data::vendors::kSaleCostCapacity> requirements{};
+    for (std::size_t index = 0; index < price.size(); ++index) {
+        requirements[index] = {.quantity = price[index].quantity,
+                               .itemDefinitionIndex = price[index].itemIndex,
+                               .deleteOnAction = true};
+    }
+    if (apply_material_requirements(
+            before,
+            std::span<const materials::Requirement>{requirements.data(), price.size()},
+            after,
+            changed)) {
+        return true;
+    }
+    after = before;
+    changed = false;
+    report_price_refusal(before, price);
+    return false;
+}
+
 /** @return True when the account holds the requested socket-action source. */
 /** @return True when the account holds the requested socket-action source. */
 [[nodiscard]] bool holds_plug_source(const AccountState& account,
 [[nodiscard]] bool holds_plug_source(const AccountState& account,
                                      std::uint32_t definitionHash) noexcept {
                                      std::uint32_t definitionHash) noexcept {

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

@@ -4,6 +4,7 @@
 #include <cstddef>
 #include <cstddef>
 #include <cstdint>
 #include <cstdint>
 #include <memory>
 #include <memory>
+#include <span>
 #include <string_view>
 #include <string_view>
 #include <type_traits>
 #include <type_traits>
 
 
@@ -99,6 +100,15 @@ apply_collection_materials(const AccountState& before,
                            const build_data::collectibles::Definition& collectible,
                            const build_data::collectibles::Definition& collectible,
                            AccountState& after,
                            AccountState& after,
                            bool& changed) noexcept;
                            bool& changed) noexcept;
+/**
+ * Spends one vendor sale row's price through the same engine Collections materials use.
+ * @param price The row's cost entries; empty charges nothing.
+ * @return True when every entry was paid, or the price was empty.
+ */
+[[nodiscard]] bool apply_sale_price(const AccountState& before,
+                                    std::span<const build_data::vendors::SaleCost> price,
+                                    AccountState& after,
+                                    bool& changed) noexcept;
 [[nodiscard]] bool
 [[nodiscard]] bool
 valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noexcept;
 valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noexcept;
 [[nodiscard]] bool materialize_profile_acquisition(const AccountState& current,
 [[nodiscard]] bool materialize_profile_acquisition(const AccountState& current,