Browse Source

settle a sale row's price from its whole cost entry

Review fixes for PR 114.

- Read the whole 48-byte cost entry: item, quantity, the two expression
  descriptors at +8 and +24, and the word at +40. An entry carrying an
  expression, or a word other than 100000, makes its row conditional: the
  price depends on state this build does not evaluate, so the row refuses at
  purchase as `conditional_price`, the way conditional material rows refuse
  today. Of 5,069 entries in the installed catalog 4,706 are plain, and every
  one of Xûr's is.
- A cost array that does not read costs the row its price, not the vendor its
  catalog. The row stays in place, since a purchase names it by ordinal, and
  refuses as `unreadable_price`.
- The state/vendors layer is gone. prepare_item_acquisition and
  prepare_profile_item_acquisition take the row's cost entries as an optional
  price, spent in place of the collectible's materials through the same
  engine, and apply_sale_price logs its own refusal with what the profile held.
- Repeatable bounty tiles pay their listed price. The tile prices whatever it
  rolls; only its placeholder item is withheld.
- The cache record carries the row's price state; the format stays at 66.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mozi 2 weeks ago
parent
commit
6c0a8f8aea

+ 0 - 2
Sunrise/Sunrise.vcxproj

@@ -1959,8 +1959,6 @@
     <ClInclude Include="src\state\build_data\bounties\definition.h" />
     <ClInclude Include="src\state\build_data\bounties\definition.h" />
     <ClInclude Include="src\state\build_data\bounties\bounty_catalog.h" />
     <ClInclude Include="src\state\build_data\bounties\bounty_catalog.h" />
     <ClInclude Include="src\state\build_data\vendors\repeatable_triggers.h" />
     <ClInclude Include="src\state\build_data\vendors\repeatable_triggers.h" />
-    <ClInclude Include="src\state\vendors\charge.h" />
-    <ClInclude Include="src\state\vendors\purchase.h" />
     <ClInclude Include="src\state\build_data\progressions\definition.h" />
     <ClInclude Include="src\state\build_data\progressions\definition.h" />
     <ClInclude Include="src\state\entitlements\definition.h" />
     <ClInclude Include="src\state\entitlements\definition.h" />
     <ClInclude Include="src\state\entitlements\entitlement_runtime.h" />
     <ClInclude Include="src\state\entitlements\entitlement_runtime.h" />

+ 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. */

+ 51 - 20
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,38 +93,50 @@ read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept
 }
 }
 
 
 /**
 /**
- * Reads what one sale row charges: every row of its price-override array, in order.
- * A row charging nothing declares no override, which is data rather than a malformed row. A row
- * declaring more overrides than the catalog can hold is refused, so a charge is never a subset of
- * what the package asks for.
+ * 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 rows, or none.
- * @return True when the array is absent, or resolves, fits, 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.costs = {};
     value.costs = {};
     value.costCount = 0;
     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.classId != domain::kSaleCostRowClass || cost.count > value.costs.size()) {
+    if (cost.count != 0
+        && (cost.classId != domain::kSaleCostRowClass || cost.count > value.costs.size())) {
         return false;
         return false;
     }
     }
-    for (std::size_t row = 0; row < cost.count; ++row) {
-        const std::size_t rowAt = cost.base + (row * domain::kSaleCostRowStride);
-        domain::SaleCost& entry = value.costs[row];
-        if (!read(blob, rowAt + kSaleCostItemIndexOffset, entry.itemIndex)
-            || !read(blob, rowAt + kSaleCostQuantityOffset, entry.quantity)) {
+    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;
             return false;
         }
         }
+        conditional = conditional || firstProgram != 0 || secondProgram != 0
+                      || word != domain::kPlainCostWord;
     }
     }
     value.costCount = static_cast<std::uint8_t>(cost.count);
     value.costCount = static_cast<std::uint8_t>(cost.count);
+    value.priceState = conditional ? domain::PriceState::conditional : domain::PriceState::plain;
     return true;
     return true;
 }
 }
 
 
@@ -158,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.
@@ -175,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;
@@ -283,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)});
     }
     }
 }
 }

+ 63 - 105
Sunrise/src/server/web_service/web_service_vendor_actions.cpp

@@ -21,7 +21,6 @@
 #include "../../state/build_data/vendors/repeatable_triggers.h"
 #include "../../state/build_data/vendors/repeatable_triggers.h"
 #include "../../state/build_data/vendors/vendor_catalog.h"
 #include "../../state/build_data/vendors/vendor_catalog.h"
 #include "../../state/runtime/runtime.h"
 #include "../../state/runtime/runtime.h"
-#include "../../state/vendors/purchase.h"
 #include "internal_actions.h"
 #include "internal_actions.h"
 #include "web_service_actions.h"
 #include "web_service_actions.h"
 
 
@@ -126,16 +125,6 @@ void report_purchase(std::uint16_t opcode,
            && vendor_domain::find(entry.definitionHash, definition);
            && vendor_domain::find(entry.definitionHash, definition);
 }
 }
 
 
-/** One vendor sale row as a purchase names it, resolved from the request's two indices. */
-struct ResolvedSale {
-    /** Item the row sells. */
-    std::uint16_t itemDefinitionIndex{kUnavailableDefinitionIndex};
-    /** Row +100, or `kAbsentCategoryIndex` when the request named no sale row. */
-    std::int32_t categoryIndex{state::build_data::vendors::kAbsentCategoryIndex};
-    /** The row's own cost. */
-    state::vendors::Charge charge{};
-};
-
 /** What the substitution table said about one sale row's item. */
 /** What the substitution table said about one sale row's item. */
 enum class Substitution : std::uint8_t {
 enum class Substitution : std::uint8_t {
     /** The table does not name this item; the row grants what it names. */
     /** The table does not name this item; the row grants what it names. */
@@ -310,23 +299,26 @@ 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)());
-    const state::vendors::Charge charge = state::vendors::Charge::of(row);
-    // An exchange charges exactly one stack, so a row declaring several currencies is not one.
-    const state::build_data::vendors::SaleCost single =
-        charge.count == 1 ? charge.costs[0] : state::build_data::vendors::SaleCost{};
+    // 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 (charge.count != 1 || charge.is_free() || single.quantity > kQuantityLimit
+    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)) {
         || !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 costs=%u 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>(charge.count),
+                          static_cast<unsigned>(row.priceState),
+                          price.size(),
                           static_cast<unsigned>(single.itemIndex),
                           static_cast<unsigned>(single.itemIndex),
                           static_cast<unsigned>(single.quantity));
                           static_cast<unsigned>(single.quantity));
         return true;
         return true;
@@ -350,48 +342,23 @@ constexpr std::array<LegacyQuestStep, 3> kLegacyQuestSteps{{
     return true;
     return true;
 }
 }
 
 
-/**
- * Names a refused charge for the purchase line, and writes what the row asked for.
- * @return The refusal reason, or the caller's own reason when the charge did not refuse.
- */
-[[nodiscard]] const char* charge_refusal_reason(state::vendors::ChargeRefusal refusal,
-                                                const state::vendors::Charge& charge,
-                                                const char* grantReason) noexcept {
-    if (refusal == state::vendors::ChargeRefusal::none) {
-        return grantReason;
-    }
-    const state::build_data::vendors::SaleCost first =
-        charge.count != 0 ? charge.costs[0] : state::build_data::vendors::SaleCost{};
-    const char* const reason = refusal == state::vendors::ChargeRefusal::insufficient
-                                   ? "insufficient_currency"
-                                   : "cost_item";
-    core::log::writef(core::log::Channel::server,
-                      core::log::Level::warn,
-                      "ev=vendor stage=charge result=fail reason=%s costs=%u cost_item=%u "
-                      "quantity=%u",
-                      reason,
-                      static_cast<unsigned>(charge.count),
-                      static_cast<unsigned>(first.itemIndex),
-                      static_cast<unsigned>(first.quantity));
-    return reason;
-}
-
 /**
 /**
  * Grants one item, given the collectible that owns it and its definition index.
  * Grants one item, given the collectible that owns it and its definition index.
  * Acquisition state is keyed by collectible, so the caller resolves one first.
  * Acquisition state is keyed by collectible, so the caller resolves one first.
  * @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 purchase A vendor row's cost and weekly claim, charged instead of the collectible's
- *        material set; absent for a Collections pull, which pays with the collectible's materials.
+ * @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,
-                           const std::optional<state::vendors::Purchase>& purchase,
-                           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(
@@ -448,23 +415,15 @@ bool grant_item_definition(const middleware::web_service::Message& message,
                                     0);
                                     0);
             return false;
             return false;
         }
         }
-        state::vendors::ChargeRefusal refusal = state::vendors::ChargeRefusal::none;
-        const bool prepared =
-            purchase.has_value()
-                ? state::prepare_vendor_profile_item_acquisition(
-                      collectibleIndex, definition.definitionHash, *purchase, *mutation, refusal)
-                : state::prepare_profile_item_acquisition(
-                      collectibleIndex, definition.definitionHash, *mutation);
-        if (!prepared) {
+        if (!state::prepare_profile_item_acquisition(
+                collectibleIndex, definition.definitionHash, price, *mutation)) {
             clear_mutation(outcome);
             clear_mutation(outcome);
-            report_item_acquisition(
-                message,
-                charge_refusal_reason(
-                    refusal, purchase.value_or(state::vendors::Purchase{}).charge, "profile_state"),
-                collectibleIndex,
-                itemDefinitionIndex,
-                definition.definitionHash,
-                0);
+            report_item_acquisition(message,
+                                    "profile_state",
+                                    collectibleIndex,
+                                    itemDefinitionIndex,
+                                    definition.definitionHash,
+                                    0);
             return false;
             return false;
         }
         }
         return true;
         return true;
@@ -489,23 +448,11 @@ bool grant_item_definition(const middleware::web_service::Message& message,
                                 0);
                                 0);
         return false;
         return false;
     }
     }
-    state::vendors::ChargeRefusal refusal = state::vendors::ChargeRefusal::none;
-    const bool prepared =
-        purchase.has_value()
-            ? state::prepare_vendor_item_acquisition(
-                  collectibleIndex, definition.definitionHash, *purchase, *mutation, refusal)
-            : state::prepare_item_acquisition(
-                  collectibleIndex, definition.definitionHash, *mutation);
-    if (!prepared) {
+    if (!state::prepare_item_acquisition(
+            collectibleIndex, definition.definitionHash, price, *mutation)) {
         clear_mutation(outcome);
         clear_mutation(outcome);
         report_item_acquisition(
         report_item_acquisition(
-            message,
-            charge_refusal_reason(
-                refusal, purchase.value_or(state::vendors::Purchase{}).charge, "state"),
-            collectibleIndex,
-            itemDefinitionIndex,
-            definition.definitionHash,
-            0);
+            message, "state", collectibleIndex, itemDefinitionIndex, definition.definitionHash, 0);
         return false;
         return false;
     }
     }
     return true;
     return true;
@@ -552,16 +499,16 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
  * 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 sale Receives the row: its vendor, item, category and cost.
+ * @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,
-                                      ResolvedSale& sale,
+                                      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;
-    sale = {};
+    row = {};
     if (vendorIndex < 0 || rowIndex < 0) {
     if (vendorIndex < 0 || rowIndex < 0) {
         reason = "negative_index";
         reason = "negative_index";
         return false;
         return false;
@@ -572,14 +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;
     }
     }
-    sale.itemDefinitionIndex = row.itemIndex;
-    sale.categoryIndex = row.categoryIndex;
-    sale.charge = state::vendors::Charge::of(row);
     return true;
     return true;
 }
 }
 
 
@@ -701,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 sale The resolved row; its cost is spent only by a plain grant.
+ * @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,
-                       const ResolvedSale& sale,
+                       const state::build_data::vendors::SaleRow& row,
                        Outcome& outcome) noexcept {
                        Outcome& outcome) noexcept {
-    const std::int32_t categoryIndex = sale.categoryIndex;
-    const std::uint16_t itemDefinitionIndex = sale.itemDefinitionIndex;
+    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,
@@ -724,9 +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);
-            // A rolled bounty is free: the row's cost fields belong to its placeholder item.
-            (void)grant_item_definition(
-                message, rolledCollectible, rolledBounty, state::vendors::Purchase{}, 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;
     }
     }
@@ -759,14 +718,13 @@ void settle_vendor_row(const middleware::web_service::Message& message,
     }
     }
     std::uint16_t collectibleIndex = state::build_data::collectibles::kNoCollectibleIndex;
     std::uint16_t collectibleIndex = state::build_data::collectibles::kNoCollectibleIndex;
     const bool collected = find_collectible_for_item(granted, collectibleIndex);
     const bool collected = find_collectible_for_item(granted, collectibleIndex);
-    const state::vendors::Purchase purchase{.charge = sale.charge};
     report_purchase(opcode,
     report_purchase(opcode,
                     "ok",
                     "ok",
                     collected ? "resolved" : "resolved_no_collectible",
                     collected ? "resolved" : "resolved_no_collectible",
                     vendorIndex,
                     vendorIndex,
                     rowIndex,
                     rowIndex,
                     granted);
                     granted);
-    (void)grant_item_definition(message, collectibleIndex, granted, purchase, outcome);
+    (void)grant_item_definition(message, collectibleIndex, granted, price, outcome);
 }
 }
 
 
 /**
 /**
@@ -797,12 +755,12 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out
     // 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:
     // A rowless 904 is an interaction reply, so its slot names the interaction, not a sale row:
-    // it has no category and no cost.
-    ResolvedSale sale{};
-    const bool located = rowless ? resolve_rowless_quest(request.vendorIndex,
-                                                         request.slotIndex,
-                                                         sale.itemDefinitionIndex)
-                                 : resolve_vendor_row(request.vendorIndex, row, sale, reason);
+    // it has no category and no price.
+    state::build_data::vendors::SaleRow sale{};
+    sale.categoryIndex = state::build_data::vendors::kAbsentCategoryIndex;
+    const bool located =
+        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",
@@ -832,7 +790,7 @@ void purchase_item(const middleware::web_service::Message& message, Outcome& out
         return;
         return;
     }
     }
     const char* reason = "unknown";
     const char* reason = "unknown";
-    ResolvedSale sale{};
+    state::build_data::vendors::SaleRow sale{};
     if (!resolve_vendor_row(request.vendorIndex, request.saleIndex, sale, reason)) {
     if (!resolve_vendor_row(request.vendorIndex, request.saleIndex, sale, reason)) {
         report_purchase(purchase::kOpcode,
         report_purchase(purchase::kOpcode,
                         "fail",
                         "fail",

+ 5 - 2
Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp

@@ -74,7 +74,7 @@ bool decode(const VendorDefinitionRecord& record, vendors::Definition& value) no
     return true;
     return true;
 }
 }
 
 
-/** Encodes one vendor sale row. Unused cost rows stay zero so the packed row always matches. */
+/** 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()) {
     if (value.costCount > value.costs.size()) {
@@ -84,6 +84,7 @@ bool encode(const vendors::SaleRow& value, VendorSaleRowRecord& record) noexcept
     record.secondaryItemIndex = value.secondaryItemIndex;
     record.secondaryItemIndex = value.secondaryItemIndex;
     record.categoryIndex = value.categoryIndex;
     record.categoryIndex = value.categoryIndex;
     record.costCount = value.costCount;
     record.costCount = value.costCount;
+    record.priceState = static_cast<std::uint8_t>(value.priceState);
     for (std::size_t cost = 0; cost < value.costCount; ++cost) {
     for (std::size_t cost = 0; cost < value.costCount; ++cost) {
         record.costs[cost].itemIndex = value.costs[cost].itemIndex;
         record.costs[cost].itemIndex = value.costs[cost].itemIndex;
         record.costs[cost].quantity = value.costs[cost].quantity;
         record.costs[cost].quantity = value.costs[cost].quantity;
@@ -94,7 +95,8 @@ bool encode(const vendors::SaleRow& value, VendorSaleRowRecord& record) noexcept
 /** 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){} || record.costCount > record.costs.size()) {
+    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) {
     for (std::size_t cost = 0; cost < record.costs.size(); ++cost) {
@@ -114,6 +116,7 @@ bool decode(const VendorSaleRowRecord& record, vendors::SaleRow& value) noexcept
     value.secondaryItemIndex = record.secondaryItemIndex;
     value.secondaryItemIndex = record.secondaryItemIndex;
     value.categoryIndex = record.categoryIndex;
     value.categoryIndex = record.categoryIndex;
     value.costCount = record.costCount;
     value.costCount = record.costCount;
+    value.priceState = static_cast<vendors::PriceState>(record.priceState);
     return true;
     return true;
 }
 }
 
 

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

@@ -546,11 +546,11 @@ struct VendorDefinitionRecord {
     std::uint16_t thirdCount{};
     std::uint16_t thirdCount{};
 };
 };
 
 
-/** Disk form of one price-override row of a vendor sale row. */
+/** Disk form of one cost entry of a vendor sale row. */
 struct VendorSaleCostRecord {
 struct VendorSaleCostRecord {
     std::uint32_t quantity{};
     std::uint32_t quantity{};
     std::uint16_t itemIndex{};
     std::uint16_t itemIndex{};
-    /** Must be zero, so the packed cost row always matches. */
+    /** Must be zero, so the packed cost entry always matches. */
     std::uint16_t reserved{};
     std::uint16_t reserved{};
 };
 };
 
 
@@ -561,8 +561,10 @@ struct VendorSaleRowRecord {
     std::uint16_t itemIndex{};
     std::uint16_t itemIndex{};
     std::uint16_t secondaryItemIndex{};
     std::uint16_t secondaryItemIndex{};
     std::uint8_t costCount{};
     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::array<std::uint8_t, 3> reserved{};
+    std::array<std::uint8_t, 2> reserved{};
 };
 };
 
 
 /** Disk form of one vendor category row. */
 /** Disk form of one vendor category row. */

+ 41 - 11
Sunrise/src/state/build_data/vendors/definition.h

@@ -3,6 +3,7 @@
 #include <array>
 #include <array>
 #include <cstddef>
 #include <cstddef>
 #include <cstdint>
 #include <cstdint>
+#include <span>
 
 
 namespace sunrise::state::build_data::vendors {
 namespace sunrise::state::build_data::vendors {
 
 
@@ -23,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. */
@@ -84,23 +85,45 @@ struct Definition {
     std::uint16_t thirdCount{};
     std::uint16_t thirdCount{};
 };
 };
 
 
-/** A price-override row naming no item carries this. */
+/** A cost entry naming no item carries this. */
 inline constexpr std::uint16_t kAbsentCostItem = 0xFFFFU;
 inline constexpr std::uint16_t kAbsentCostItem = 0xFFFFU;
-/** Price-override rows one sale row may declare. A row declaring more is refused, not truncated. */
+/** Cost entries one sale row may declare. The widest row in the installed catalog declares four. */
 inline constexpr std::size_t kSaleCostCapacity = 4;
 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 price-override row (sale row +32 array, `kSaleCostRowClass`): what the sale charges.
- * Observed on Xûr's definition: item 128 with 29, 23, 97 and 9 units, which are exactly the
- * Legendary Shard prices of his weapon, armour, Fated Engram and Invitation of the Nine rows.
+ * 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 {
 struct SaleCost {
-    /** Override row +0. Cost item-definition index. */
+    /** Entry +0. Cost item-definition index. */
     std::uint16_t itemIndex{kAbsentCostItem};
     std::uint16_t itemIndex{kAbsentCostItem};
-    /** Override row +4. Units charged. */
+    /** Entry +4. Units charged. */
     std::uint32_t quantity{};
     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. */
@@ -109,12 +132,19 @@ struct SaleRow {
     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{};
-    /** Every price-override row, in declared order. The row charges all of them together. */
+    /** Every cost entry, in declared order. A plain row charges all of them together. */
     std::array<SaleCost, kSaleCostCapacity> costs{};
     std::array<SaleCost, kSaleCostCapacity> costs{};
-    /** Rows of `costs` in use. Zero when the row charges nothing. */
+    /** Entries of `costs` in use. Zero when the row charges nothing. */
     std::uint8_t costCount{};
     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 - 43
Sunrise/src/state/runtime/runtime.h

@@ -3,12 +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 "../vendors/purchase.h"
+#include "../build_data/vendors/definition.h"
 #include "state.h"
 #include "state.h"
 
 
 namespace sunrise::state::account::settings {
 namespace sunrise::state::account::settings {
@@ -521,35 +522,23 @@ 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,
                                                      PendingItemAcquisition& mutation) noexcept;
                                                      PendingItemAcquisition& mutation) noexcept;
 
 
-/**
- * Prepares one vendor sale row's character-item grant, charging the row's own cost instead of a
- * collectible's material set.
- * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
- * @param definitionHash Installed item definition the row sells.
- * @param purchase The row's cost.
- * @param mutation Gets a checked after-image without changing account State.
- * @param refusal Receives why the cost refused, or none when the grant itself refused.
- * @return True when the charge and the grant both fit the account.
- */
-[[nodiscard]] bool prepare_vendor_item_acquisition(std::uint16_t collectibleIndex,
-                                                   std::uint32_t definitionHash,
-                                                   const vendors::Purchase& purchase,
-                                                   PendingItemAcquisition& mutation,
-                                                   vendors::ChargeRefusal& refusal) noexcept;
-
 /** Prepares one fixed wrapper expansion without changing account State. */
 /** Prepares one fixed wrapper expansion without changing account State. */
 [[nodiscard]] bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash,
 [[nodiscard]] bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash,
                                               std::span<const std::uint16_t> itemDefinitionIndices,
                                               std::span<const std::uint16_t> itemDefinitionIndices,
@@ -608,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
@@ -624,23 +616,6 @@ prepare_profile_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
                                           std::int32_t quantity,
                                           std::int32_t quantity,
                                           PendingProfileItemAcquisition& mutation) noexcept;
                                           PendingProfileItemAcquisition& mutation) noexcept;
 
 
-/**
- * Prepares one vendor sale row's profile-stack grant, charging the row's own cost instead of a
- * collectible's material set.
- * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
- * @param definitionHash Installed stackable definition the row sells.
- * @param purchase The row's cost.
- * @param mutation Gets the checked profile before/after images without changing account State.
- * @param refusal Receives why the cost refused, or none when the grant itself refused.
- * @return True when the charge and one unit of the item both fit the profile.
- */
-[[nodiscard]] bool
-prepare_vendor_profile_item_acquisition(std::uint16_t collectibleIndex,
-                                        std::uint32_t definitionHash,
-                                        const vendors::Purchase& purchase,
-                                        PendingProfileItemAcquisition& mutation,
-                                        vendors::ChargeRefusal& refusal) noexcept;
-
 /**
 /**
  * Materializes a prepared profile acquisition over the current account only while its complete
  * Materializes a prepared profile acquisition over the current account only while its complete
  * profile-inventory view is unchanged. This is the account object encoded before commit.
  * profile-inventory view is unchanged. This is the account object encoded before commit.

+ 32 - 119
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,72 +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: a Collections pull pays with the collectible's
-    // material requirements. A vendor row's own cost is spent by the prepare_vendor_* siblings.
-    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;
     }
     }
 
 
-    return finalize_item_acquisition(
-        account,
-        chargedAccount,
-        definitionHash,
-        profileChanged,
-        {.materialRequirementSetHash = collectible.materialRequirementSetHash,
-         .collectibleIndex = collectibleIndex,
-         .materialRequirementCount = collectible.materialRequirementCount},
-        mutation);
-}
-
-/**
- * A sale row's cost replaces the collectible's material set; the collectible, when the row has
- * one, still names the grant so commit can re-check it.
- * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
- * @param definitionHash Item definition the row sells.
- * @param purchase The row's cost.
- * @param mutation Receives a pending grant; prepared is set only on success.
- * @param refusal Receives why the cost refused, or none when the grant itself refused.
- * @return False when identity, cost, capacity, or saved state prevent the grant.
- */
-bool prepare_vendor_item_acquisition(std::uint16_t collectibleIndex,
-                                     std::uint32_t definitionHash,
-                                     const vendors::Purchase& purchase,
-                                     PendingItemAcquisition& mutation,
-                                     vendors::ChargeRefusal& refusal) noexcept {
-    const std::lock_guard lock(investment::store::g_mutex);
-    mutation = {};
-    refusal = vendors::ChargeRefusal::none;
-    const AccountState account = account_snapshot();
-    build_data::collectibles::Definition collectible{};
-    build_data::items::Definition grantedDefinition{};
-    const bool hasCollectible = collectibleIndex != build_data::collectibles::kNoCollectibleIndex;
-    if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
-        || !valid_profile_inventory(account)) {
-        return false;
-    }
-    if (hasCollectible) {
-        if (!build_data::find_collectible_definition(collectibleIndex, collectible)
-            || collectible.itemDefinitionIndex
-                   == build_data::collectibles::kUnavailableItemDefinitionIndex
-            || !build_data::find_item_definition_index(collectible.itemDefinitionIndex,
-                                                       grantedDefinition)
-            || grantedDefinition.definitionHash != definitionHash) {
-            return false;
-        }
-    } else if (!build_data::find_item_definition_hash(definitionHash, grantedDefinition)
-               || grantedDefinition.definitionHash != definitionHash) {
-        return false;
-    }
-
-    AccountState chargedAccount = account;
-    bool profileChanged = false;
-    if (!apply_sale_charge(account, purchase.charge, chargedAccount, profileChanged, refusal)) {
-        return false;
-    }
-
-    // Commit re-checks the collectible's own cost fields, so the mutation carries those and the
-    // sale charge is proven only by its before/after profile images.
+    // 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,
@@ -770,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{};
@@ -795,63 +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: a Collections pull pays with the collectible's
-    // material requirements. A vendor row's own cost is spent by the prepare_vendor_* siblings.
-    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);
 
 
-    return finalize_profile_item_acquisition(
-        account,
-        chargedAccount,
-        definitionHash,
-        detail,
-        actionSource,
-        1,
-        {.materialRequirementSetHash = collectible.materialRequirementSetHash,
-         .collectibleIndex = collectibleIndex,
-         .materialRequirementCount = collectible.materialRequirementCount},
-        mutation);
-}
-
-/** Prepares one vendor sale row's profile-stack grant, charging the row's own cost. */
-bool prepare_vendor_profile_item_acquisition(std::uint16_t collectibleIndex,
-                                             std::uint32_t definitionHash,
-                                             const vendors::Purchase& purchase,
-                                             PendingProfileItemAcquisition& mutation,
-                                             vendors::ChargeRefusal& refusal) noexcept {
-    mutation = {};
-    refusal = vendors::ChargeRefusal::none;
-    const AccountState account = account_snapshot();
-    build_data::collectibles::Definition collectible{};
-    build_data::items::Definition item{};
-    item_details::Definition detail{};
-    if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
-        || !valid_profile_inventory(account)
-        || !build_data::find_item_definition_hash(definitionHash, item)
-        || (collectibleIndex != build_data::collectibles::kNoCollectibleIndex
-            && (!build_data::find_collectible_definition(collectibleIndex, collectible)
-                || collectible.itemDefinitionIndex
-                       == build_data::collectibles::kUnavailableItemDefinitionIndex
-                || item.definitionIndex != collectible.itemDefinitionIndex))
-        || !resolve_profile_item(item.definitionIndex, item, detail)
-        || item.definitionHash != definitionHash) {
-        return false;
-    }
-    AccountState chargedAccount = account;
-    bool profileChanged = false;
-    if (!apply_sale_charge(account, purchase.charge, chargedAccount, profileChanged, refusal)) {
-        return false;
-    }
-    const bool actionSource =
-        build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
-
-    // Commit re-checks the collectible's own cost fields, so the mutation carries those and the
-    // sale charge is proven only by its before/after profile images.
+    // 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,

+ 49 - 48
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,67 +295,67 @@ apply_collection_materials(const AccountState& before,
         changed);
         changed);
 }
 }
 
 
-/** One vendor price-override row, shaped so the material engine consumes it unchanged. */
-struct SaleRequirement {
-    std::uint16_t itemDefinitionIndex{};
-    std::uint32_t quantity{};
-    bool deleteOnAction{true};
-};
-
-/** @return True when the engine could debit this item: a stackable profile item, not a source. */
-[[nodiscard]] static bool payable_cost_item(std::uint16_t itemDefinitionIndex) noexcept {
-    build_data::items::Definition definition{};
-    item_details::Definition detail{};
-    inventory_buckets::Descriptor bucket{};
-    return itemDefinitionIndex != build_data::vendors::kAbsentCostItem
-           && build_data::find_item_definition_index(itemDefinitionIndex, definition)
-           && definition.definitionIndex == itemDefinitionIndex
-           && build_data::find_configured_item_detail(itemDefinitionIndex, detail)
-           && detail.definitionIndex == itemDefinitionIndex
-           && detail.definitionHash == definition.definitionHash
-           && detail.bucketId == definition.bucketId
-           && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable
-           && build_data::find_inventory_bucket_descriptor(definition.bucketId, bucket)
-           && bucket.arraySelector == inventory_buckets::ArraySelector::profile
-           && !build_data::is_profile_action_source(definition.definitionIndex,
-                                                    definition.bucketId);
+/**
+ * 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));
+    }
 }
 }
 
 
-[[nodiscard]] bool apply_sale_charge(const AccountState& before,
-                                     const vendors::Charge& charge,
-                                     AccountState& after,
-                                     bool& changed,
-                                     vendors::ChargeRefusal& refusal) noexcept {
+/**
+ * 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;
     after = before;
     changed = false;
     changed = false;
-    refusal = vendors::ChargeRefusal::none;
-    if (charge.is_free()) {
-        return true;
+    if (price.size() > build_data::vendors::kSaleCostCapacity) {
+        return false;
     }
     }
-    std::array<SaleRequirement, build_data::vendors::kSaleCostCapacity> requirements{};
-    std::size_t requirementCount = 0;
-    for (const build_data::vendors::SaleCost& cost : charge.entries()) {
-        if (cost.quantity == 0) {
-            continue;
-        }
-        // A cost row the engine cannot debit is a row this build does not understand, which is
-        // refused as its own thing rather than reported as an empty wallet.
-        if (!payable_cost_item(cost.itemIndex)) {
-            refusal = vendors::ChargeRefusal::malformedCost;
-            return false;
-        }
-        requirements[requirementCount++] = {cost.itemIndex, cost.quantity, true};
+    // 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<SaleRequirement>(
+    if (apply_material_requirements(
             before,
             before,
-            std::span<const SaleRequirement>{requirements.data(), requirementCount},
+            std::span<const materials::Requirement>{requirements.data(), price.size()},
             after,
             after,
             changed)) {
             changed)) {
         return true;
         return true;
     }
     }
     after = before;
     after = before;
     changed = false;
     changed = false;
-    refusal = vendors::ChargeRefusal::insufficient;
+    report_price_refusal(before, price);
     return false;
     return false;
 }
 }
 
 

+ 8 - 8
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>
 
 
@@ -100,15 +101,14 @@ apply_collection_materials(const AccountState& before,
                            AccountState& after,
                            AccountState& after,
                            bool& changed) noexcept;
                            bool& changed) noexcept;
 /**
 /**
- * Applies one sale row's cost through the same validation Collections materials use.
- * @param refusal Receives why the charge was refused, or none.
- * @return True when every cost row was payable and paid, or the row was free.
+ * 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_charge(const AccountState& before,
-                                     const vendors::Charge& charge,
-                                     AccountState& after,
-                                     bool& changed,
-                                     vendors::ChargeRefusal& refusal) noexcept;
+[[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,

+ 0 - 52
Sunrise/src/state/vendors/charge.h

@@ -1,52 +0,0 @@
-#pragma once
-
-#include <array>
-#include <cstddef>
-#include <cstdint>
-#include <span>
-
-#include "../build_data/vendors/definition.h"
-
-namespace sunrise::state::vendors {
-
-/**
- * What one vendor sale row charges: every price-override row it declares, spent together.
- * Every path that spends a row's cost builds one of these from the row, so the cost fields are
- * read in exactly one place and a free row is recognised the same way everywhere.
- */
-struct Charge {
-    std::array<build_data::vendors::SaleCost, build_data::vendors::kSaleCostCapacity> costs{};
-    std::uint8_t count{};
-
-    /** @return The cost one sale row carries. */
-    [[nodiscard]] static constexpr Charge of(const build_data::vendors::SaleRow& row) noexcept {
-        return {row.costs, row.costCount};
-    }
-
-    /** @return The declared cost rows, in order. */
-    [[nodiscard]] std::span<const build_data::vendors::SaleCost> entries() const noexcept {
-        return {costs.data(), count > costs.size() ? std::size_t{0} : std::size_t{count}};
-    }
-
-    /** @return True when nothing is charged: no cost row names an item with a nonzero quantity. */
-    [[nodiscard]] constexpr bool is_free() const noexcept {
-        for (std::size_t index = 0; index < count && index < costs.size(); ++index) {
-            if (costs[index].itemIndex != build_data::vendors::kAbsentCostItem
-                && costs[index].quantity != 0) {
-                return false;
-            }
-        }
-        return true;
-    }
-};
-
-/** Why one sale charge was refused. */
-enum class ChargeRefusal : std::uint8_t {
-    none,
-    /** A cost row names something the account cannot pay with: not a stackable profile item. */
-    malformedCost,
-    /** Every cost row is payable, but the account holds too little of one. */
-    insufficient,
-};
-
-} // namespace sunrise::state::vendors

+ 0 - 13
Sunrise/src/state/vendors/purchase.h

@@ -1,13 +0,0 @@
-#pragma once
-
-#include "charge.h"
-
-namespace sunrise::state::vendors {
-
-/** Everything one vendor sale row asks of the account beyond the item it grants. */
-struct Purchase {
-    /** The row's cost, spent with the grant. */
-    Charge charge{};
-};
-
-} // namespace sunrise::state::vendors