ソースを参照

Buy from a vendor: purchases, quests, bounties and recycling

Opcode 901 resolves a sale row to its item and grants it through the
same acquisition path a Collections pull uses. Cost is deliberately not
charged: the row's cost-bearing fields are still role-open. Opcode 904
acquires a quest the same way; its codec is settled by captured
requests, including the 32-bit sale-row field whose -1 marks a tile
that is not a sale row at all.

Both opcodes end in one ordered chain. A row is a bounty roll, an
exchange, or a grant, each recognised by an authored rule keyed to the
vendor: vendor_bounty_roll.txt names the repeatable pool an Additional
Bounties row draws from (five held, as retail allows), vendor_exchange.txt
names what a recycle row charges and pays, and vendor_item_substitute.txt
names what a placeholder row really grants. Every refusal is fail-closed,
on the rule that a wrong grant which commits cleanly is worse than a
refusal: a matched rule that cannot be honoured owns its row and grants
nothing, and a 904 body without the sale-row field is refused rather
than guessed at.

The two codecs share one biased-index reader. The 901 clock rule, which
had lost its only caller, is gone; the clock is still decoded so the
body is checked whole. The routes table and the mutating reply path
disagree about 901's shape; the working form is left alone and the
disagreement is recorded where a reader would otherwise correct it.
chnsw 6 日 前
コミット
328ddf35ce

+ 3 - 0
Sunrise/Sunrise.vcxproj

@@ -137,6 +137,7 @@
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_device.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode504_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode701\opcode701_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode904\opcode904_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode903_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode1901_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode402_codec.cpp" />
@@ -1219,6 +1220,8 @@
     <ClInclude Include="src\client\hooks\banner\banner_hook_lifecycle.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode504.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode701\opcode701_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode904\opcode904_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\biased_field.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode903.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode1901.h" />
     <ClInclude Include="src\core\ui\busy\busy.h" />

+ 34 - 0
Sunrise/src/middleware/web_service/messages/biased_field.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages {
+
+/** The vendor opcodes carry their indices as 16-bit signed fields. */
+inline constexpr std::uint8_t kBiasedIndexWidth = 16;
+/** Their descriptor bias is the signed 16-bit midpoint. */
+inline constexpr std::int32_t kBiasedIndexBias = 0x8000;
+
+/**
+ * Reads one biased 16-bit index field.
+ *
+ * Opcodes 901 and 904 both open with fields of this shape, and each codec carried its own copy of
+ * the reader. One copy means the two cannot drift.
+ *
+ * @param reader Open reader.
+ * @param output Receives the logical index.
+ * @return True when the field was present.
+ */
+[[nodiscard]] inline bool read_biased_index(encoding::bits::Reader& reader,
+                                            std::int16_t& output) noexcept {
+    std::uint64_t stored = 0;
+    if (!reader.read(kBiasedIndexWidth, stored)) {
+        return false;
+    }
+    output = static_cast<std::int16_t>(static_cast<std::int32_t>(stored) - kBiasedIndexBias);
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages

+ 5 - 54
Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.cpp

@@ -1,20 +1,16 @@
 /**
- * Opcode 901 is a vendor purchase. The request carries a vendor index, a sale index and a clock.
- * The clock rule checks skew and freshness only. Replay needs a committed purchase to compare
- * against, and no purchase commits yet.
+ * Opcode 901 is a vendor purchase. The request carries a vendor index, a sale index and an
+ * optional clock. The clock is decoded so the body is checked whole; nothing reads it yet.
  */
 
 #include "opcode901_codec.h"
 
 #include "../../../encoding/bit_reader.h"
+#include "../biased_field.h"
 
 namespace sunrise::middleware::web_service::messages::opcode901 {
 namespace {
 
-/** Both index fields are 16-bit signed values. */
-constexpr std::uint8_t kIndexWidth = 16;
-/** Their descriptor bias is the signed 16-bit midpoint. */
-constexpr std::int32_t kIndexBias = 0x8000;
 /** The optional clock is a 64-bit signed value with no bias. */
 constexpr std::uint8_t kClockWidth = 64;
 /** One presence bit precedes the clock. */
@@ -25,21 +21,6 @@ constexpr std::uint8_t kPresenceWidth = 1;
  */
 constexpr std::size_t kPaddingLimit = 8;
 
-/**
- * Reads one biased index field.
- * @param reader Open reader.
- * @param output Receives the logical index.
- * @return True when the field was present.
- */
-[[nodiscard]] bool read_index(encoding::bits::Reader& reader, std::int16_t& output) noexcept {
-    std::uint64_t stored = 0;
-    if (!reader.read(kIndexWidth, stored)) {
-        return false;
-    }
-    output = static_cast<std::int16_t>(static_cast<std::int32_t>(stored) - kIndexBias);
-    return true;
-}
-
 } // namespace
 
 /** Decodes one purchase request body. */
@@ -50,7 +31,8 @@ bool parse_request(const Message& message, Request& output) noexcept {
     encoding::bits::Reader reader(message.payload);
     Request candidate{};
     std::uint64_t present = 0;
-    if (!read_index(reader, candidate.vendorIndex) || !read_index(reader, candidate.saleIndex)
+    if (!read_biased_index(reader, candidate.vendorIndex)
+        || !read_biased_index(reader, candidate.saleIndex)
         || !reader.read(kPresenceWidth, present)) {
         return false;
     }
@@ -69,35 +51,4 @@ bool parse_request(const Message& message, Request& output) noexcept {
     return true;
 }
 
-/** Checks presence first, then both windows. */
-ClockPolicy check_clock(const Request& request, std::int64_t serverClock) noexcept {
-    if (!request.hasClock) {
-        return ClockPolicy::absent;
-    }
-    // Unsigned subtraction keeps the distance exact for any two signed clocks.
-    if (request.clock > serverClock) {
-        const std::uint64_t ahead =
-            static_cast<std::uint64_t>(request.clock) - static_cast<std::uint64_t>(serverClock);
-        return ahead > kClockAheadLimitSeconds ? ClockPolicy::ahead : ClockPolicy::accepted;
-    }
-    const std::uint64_t behind =
-        static_cast<std::uint64_t>(serverClock) - static_cast<std::uint64_t>(request.clock);
-    return behind > kClockBehindLimitSeconds ? ClockPolicy::stale : ClockPolicy::accepted;
-}
-
-/** Names one clock verdict for a log line. */
-const char* clock_policy_name(ClockPolicy policy) noexcept {
-    switch (policy) {
-    case ClockPolicy::accepted:
-        return "ok";
-    case ClockPolicy::absent:
-        return "absent";
-    case ClockPolicy::ahead:
-        return "ahead";
-    case ClockPolicy::stale:
-        return "stale";
-    }
-    return "unknown";
-}
-
 } // namespace sunrise::middleware::web_service::messages::opcode901

+ 1 - 37
Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.h

@@ -16,32 +16,12 @@ struct Request {
     std::int16_t vendorIndex{};
     /** Index into that vendor's sale rows. */
     std::int16_t saleIndex{};
-    /** Client clock in Unix seconds. */
+    /** Client clock in Unix seconds. Decoded so the body is checked whole; no rule reads it. */
     std::int64_t clock{};
     /** False for the absent form, which the decoder still accepts. */
     bool hasClock{};
 };
 
-/**
- * Verdict of the clock rule on one decoded request.
- * Every value is Sunrise policy, not a native rule.
- */
-enum class ClockPolicy : std::uint8_t {
-    /** Present, and inside both windows. */
-    accepted,
-    /** The absent form. It decodes, but the clock rule refuses it. */
-    absent,
-    /** Ahead of the server by more than the skew window. */
-    ahead,
-    /** Behind the server by more than the freshness window. */
-    stale,
-};
-
-/** Seconds a request clock may run ahead of the server. Policy window, not a native value. */
-inline constexpr std::uint64_t kClockAheadLimitSeconds = 60;
-/** Seconds a request clock may run behind the server. Policy window, not a native value. */
-inline constexpr std::uint64_t kClockBehindLimitSeconds = 300;
-
 /**
  * Decodes one purchase request body.
  * A body with a whole byte left over after the last field is refused.
@@ -51,20 +31,4 @@ inline constexpr std::uint64_t kClockBehindLimitSeconds = 300;
  */
 [[nodiscard]] bool parse_request(const Message& message, Request& output) noexcept;
 
-/**
- * Applies the clock rule to one decoded request.
- * The clock must be present. The absent form decodes, then fails here.
- * @param request Decoded request.
- * @param serverClock Server's own time in Unix seconds.
- * @return Which rule the clock met or broke.
- */
-[[nodiscard]] ClockPolicy check_clock(const Request& request, std::int64_t serverClock) noexcept;
-
-/**
- * Names one clock verdict for a log line.
- * @param policy Verdict from check_clock.
- * @return Stable lowercase token.
- */
-[[nodiscard]] const char* clock_policy_name(ClockPolicy policy) noexcept;
-
 } // namespace sunrise::middleware::web_service::messages::opcode901

+ 56 - 0
Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp

@@ -0,0 +1,56 @@
+/**
+ * Opcode 904 acquires a quest or other pursuit from a vendor.
+ *
+ * Captured requests settle the layout: three 16-bit fields biased by `0x8000` - the vendor, the
+ * clicked UI slot, and a field zero in every capture - then one 32-bit field biased by `0x80000000`
+ * naming the sale row, then one trailing byte skipped rather than guessed at.
+ *
+ * | payload | vendor | slot | third | sale row |
+ * |---|---|---|---|---|
+ * | `8016 801C 8000 80000104 00` | 22 | 28 | 0 | 260 |
+ * | `8016 801D 8000 800000C3 00` | 22 | 29 | 0 | 195 |
+ * | `8016 8020 8000 80000109 00` | 22 | 32 | 0 | 265 |
+ * | `8016 8015 8000 7FFFFFFF 00` | 22 | 21 | 0 | -1  |
+ *
+ * The last row fixes the width: `7FFFFFFF` is -1 under the 32-bit bias, the same absent marker a
+ * sale row's own category index carries. Read as a bare 16-bit field it would be 65535.
+ */
+
+#include "opcode904_codec.h"
+
+#include "../../../encoding/bit_reader.h"
+#include "../biased_field.h"
+
+namespace sunrise::middleware::web_service::messages::opcode904 {
+namespace {
+
+/** The sale row is a 32-bit signed value. */
+constexpr std::uint8_t kSaleIndexWidth = 32;
+/** Its bias is the signed 32-bit midpoint, which is the same rule one width up. */
+constexpr std::int64_t kSaleIndexBias = 0x80000000;
+
+} // namespace
+
+/** Decodes one quest-acquire request body. */
+bool parse_request(const Message& message, Request& output) noexcept {
+    if (message.opcode != kOpcode) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    Request candidate{};
+    if (!read_biased_index(reader, candidate.vendorIndex)
+        || !read_biased_index(reader, candidate.slotIndex)
+        || !read_biased_index(reader, candidate.third)) {
+        return false;
+    }
+    std::uint64_t saleIndex = 0;
+    if (reader.read(kSaleIndexWidth, saleIndex)) {
+        candidate.saleIndex =
+            static_cast<std::int32_t>(static_cast<std::int64_t>(saleIndex) - kSaleIndexBias);
+        candidate.hasSaleIndex = true;
+    }
+    output = candidate;
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode904

+ 50 - 0
Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h

@@ -0,0 +1,50 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode904 {
+
+/** Web Service opcode for acquiring a quest or other pursuit from a vendor. */
+inline constexpr std::uint16_t kOpcode = 904;
+
+/**
+ * One decoded quest-acquire request.
+ *
+ * Structurally a sibling of the opcode-901 purchase: 16-bit fields biased by `0x8000`. It carries
+ * three of them and no clock, where 901 carries two and an optional clock, and then one 32-bit
+ * field biased by `0x80000000` that names the sale row.
+ */
+struct Request {
+    /** Index into the vendor table, the same table 901 indexes. */
+    std::int16_t vendorIndex{};
+    /**
+     * UI slot the click landed on. Not a sale row: indexing sale rows with it grants armour mods.
+     */
+    std::int16_t slotIndex{};
+    /** Third field. Zero in every captured request; role open. */
+    std::int16_t third{};
+    /**
+     * Sale row of the vendor definition, as a 32-bit field biased by `0x80000000`. `7FFFFFFF` is
+     * -1, the same absent marker a sale row's own category index uses; only the full width reads
+     * it as such.
+     */
+    std::int32_t saleIndex{};
+    /** True when the body carried the sale-row field. */
+    bool hasSaleIndex{};
+};
+
+/**
+ * Decodes one quest-acquire request body.
+ *
+ * Unlike 901 this does not refuse a body with a byte to spare: a captured request is 11 bytes and
+ * the four fields account for ten. The trailing byte is skipped rather than read as a field.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param output Receives the request only when the three leading fields decode.
+ * @return True when the opcode matches and those fields are present.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& output) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode904

+ 9 - 1
Sunrise/src/server/web_service/opcode_routes.cpp

@@ -21,7 +21,15 @@ constexpr auto kStatusPairOpcodes = std::to_array<std::uint16_t>({
     1617, 1618, 1701, 1702, 1801, 1802, 1803, 1820, 1821, 1901, 2002, 2200, 2300, 2400,
 });
 
-/** Opcodes whose status pair has one required trailing boolean field. */
+/**
+ * Opcodes whose status pair has one required trailing boolean field.
+ *
+ * 901 is listed from its refusal form. A purchase that prepares a mutation is answered elsewhere:
+ * `bap_service_body.cpp` re-encodes it as a plain status pair, one bit short of this shape, and
+ * that is the reply verified working in game. The two disagree and nothing reconciles them. Only a
+ * captured retail reply to a successful 901 can say which is right, so until one exists neither
+ * side is changed - the working form is the one the game has already accepted.
+ */
 constexpr auto kStatusPairBoolOpcodes = std::to_array<std::uint16_t>({104, 901});
 
 /** Opcodes whose response definition hands its status value to the Client's Family-4 wait. */

+ 848 - 31
Sunrise/src/server/web_service/web_service_actions.cpp

@@ -1,11 +1,16 @@
 #include "web_service_actions.h"
 
 #include <array>
+#include <chrono>
 #include <cstdio>
+#include <cstring>
 #include <limits>
+#include <span>
 #include <string_view>
 
+#include "../../core/filesystem/path.h"
 #include "../../core/logging/log.h"
+#include "../../core/settings/rule_text.h"
 #include "../../middleware/web_service/messages/opcode1820.h"
 #include "../../middleware/web_service/messages/opcode1901.h"
 #include "../../middleware/web_service/messages/opcode402.h"
@@ -14,10 +19,16 @@
 #include "../../middleware/web_service/messages/opcode504.h"
 #include "../../middleware/web_service/messages/opcode701/opcode701_codec.h"
 #include "../../middleware/web_service/messages/opcode801.h"
+#include "../../middleware/web_service/messages/opcode901/opcode901_codec.h"
 #include "../../middleware/web_service/messages/opcode903.h"
+#include "../../middleware/web_service/messages/opcode904/opcode904_codec.h"
 #include "../../state/account/account_state.h"
+#include "../../state/account/pursuit_hold.h"
+#include "../../state/build_data/items/item_catalog.h"
 #include "../../state/build_data/runtime.h"
+#include "../../state/build_data/vendors/vendor_catalog.h"
 #include "../../state/runtime/runtime.h"
+#include "../../state/vendors/answered_interactions.h"
 #include "internal.h"
 
 namespace sunrise::server::web_service {
@@ -28,6 +39,24 @@ namespace {
 constexpr std::uint8_t kEquippedShaderModelSocketKind = 0;
 /** Index stored when no definition resolves. The catalog is u16-indexed, so this cannot be one. */
 constexpr std::uint32_t kUnavailableDefinitionIndex = (std::numeric_limits<std::uint16_t>::max)();
+/** Repeatable bounties a character may hold from one vendor at once, as retail allows. */
+constexpr std::uint32_t kRepeatableHoldLimit = 5;
+/** Authored repeatable pool ceiling. The largest set in the manifest is Eva's Dawning, at 22. */
+constexpr std::size_t kRepeatablePoolCapacity = 64;
+/** Stacks one exchange row may credit. Shader recycling pays two: Glimmer and Legendary Shards. */
+constexpr std::size_t kExchangePayoutCapacity = 4;
+// Every credited stack is announced to the account's change ring, so a rule that named more
+// payouts than the mutation can announce would pay out silently. Raising one raises the other.
+static_assert(kExchangePayoutCapacity <= state::kProfileStackChangeCapacity);
+
+/**
+ * Storage every rule reader in this file parses from.
+ *
+ * The three readers run one after another on the request thread, each reading its file and
+ * finishing with it before the next starts, so they share one buffer rather than holding one
+ * each. A reader must not keep a cursor into it across a call to another reader.
+ */
+std::array<char, core::rule_text::kRuleTextCapacity> g_ruleText{};
 /** One line carries the picked id and whether the selection moved. */
 constexpr std::size_t kSelectLineCapacity = 96;
 
@@ -625,38 +654,373 @@ void dismantle_item(const middleware::web_service::Message& message, Outcome& ou
                                  kSingleQuantity);
 }
 
-/** Prepares the exact three-byte opcode-1820 Collections item request. */
-void acquire_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
-    middleware::web_service::messages::opcode1820::Request request{};
-    if (!middleware::web_service::messages::opcode1820::parse_request(message, request)) {
-        report_acquisition_preparation(message,
-                                       "fail",
-                                       "payload_bits",
-                                       kUnavailableDefinitionIndex,
-                                       kUnavailableDefinitionIndex,
-                                       0,
-                                       0);
-        return;
+/**
+ * Writes one purchase line.
+ *
+ * The opcode is carried rather than hard-coded: 901 and 904 share this line, and a quest acquire
+ * reporting itself as `ws901` sends anyone reading the log to the wrong decoder.
+ *
+ * @param opcode Request opcode the line belongs to, 901 or 904.
+ * @param result `ok` or `fail`.
+ * @param reason Step that decided it.
+ * @param vendorIndex Vendor row the request named.
+ * @param saleIndex Sale row the request named.
+ * @param itemDefinitionIndex Item resolved, when the row resolved.
+ */
+void report_purchase(std::uint16_t opcode,
+                     const char* result,
+                     const char* reason,
+                     std::int32_t vendorIndex,
+                     std::int32_t saleIndex,
+                     std::uint16_t itemDefinitionIndex) noexcept {
+    core::log::writef(core::log::Channel::server,
+                      std::strcmp(result, "ok") == 0 ? core::log::Level::info
+                                                     : core::log::Level::warn,
+                      "ev=ws%u stage=purchase result=%s reason=%s vendor=%d sale=%d item=%u",
+                      static_cast<unsigned>(opcode),
+                      result,
+                      reason,
+                      static_cast<int>(vendorIndex),
+                      static_cast<int>(saleIndex),
+                      static_cast<unsigned>(itemDefinitionIndex));
+}
+
+/**
+ * Resolves the vendor a request names to its index row and held definition.
+ *
+ * Every vendor behaviour starts here, and five of them spelled it out by hand. A negative index is
+ * the client's own absent marker and never a row.
+ *
+ * @param vendorIndex Vendor row the request named.
+ * @param entry Receives the index row.
+ * @param definition Receives the held definition.
+ * @return True when the row exists and its definition is published.
+ */
+[[nodiscard]] bool find_vendor(std::int32_t vendorIndex,
+                               state::build_data::vendors::IndexEntry& entry,
+                               state::build_data::vendors::Definition& definition) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    entry = {};
+    definition = {};
+    return vendorIndex >= 0 && vendorIndex <= (std::numeric_limits<std::uint16_t>::max)()
+           && vendor_domain::find_index(static_cast<std::uint16_t>(vendorIndex), entry)
+           && vendor_domain::find(entry.definitionHash, definition);
+}
+
+/** What a substitution rule said about one sale row's item. */
+enum class Substitution : std::uint8_t {
+    /** No rule names this item; the row grants what it names. */
+    none,
+    /** A rule names it and its replacement resolved; the row grants the replacement. */
+    replaced,
+    /** A rule names it but its replacement is not in this build; the row must grant nothing. */
+    broken,
+};
+
+/**
+ * Answers what a placeholder sale row is really selling.
+ *
+ * Several rows name a DestinyItemType 20 Dummy - a UI placeholder for something the row does not
+ * name, as Amanda Holliday's Legacy Content rows stand for a campaign's first quest step. Granting
+ * the placeholder puts an item in the Quests tab the client will not draw, and the row never
+ * settles. `vendor_item_substitute.txt` maps sold hash to granted hash, keyed by item so one rule
+ * covers every seller. A rule whose replacement is absent from this build answers `broken` rather
+ * than `none`: the rule proves the row's item is a placeholder, and granting it would be the exact
+ * wrong grant this file exists to prevent.
+ *
+ * @param itemDefinitionIndex Item the row resolved to.
+ * @param substituteIndex Receives what should be granted in its place.
+ * @return What the rule file said about this item.
+ */
+[[nodiscard]] Substitution substitute_for_item(std::uint16_t itemDefinitionIndex,
+                                               std::uint16_t& substituteIndex) noexcept {
+    substituteIndex = kUnavailableDefinitionIndex;
+    state::build_data::items::Definition sold{};
+    if (!state::build_data::find_item_definition_index(itemDefinitionIndex, sold)) {
+        return Substitution::none;
     }
-    const std::uint16_t collectibleIndex = request.collectibleIndex;
-    std::uint16_t itemDefinitionIndex = 0;
-    if (!state::build_data::find_collectible_item_definition_index(collectibleIndex,
-                                                                   itemDefinitionIndex)) {
-        report_acquisition_preparation(message,
-                                       "fail",
-                                       "collectible_definition",
-                                       collectibleIndex,
-                                       kUnavailableDefinitionIndex,
-                                       0,
-                                       0);
-        return;
+    if (!core::path::read_artifact_text(L"vendor_item_substitute.txt", g_ruleText)) {
+        return Substitution::none;
     }
+    core::rule_text::Cursor rules{g_ruleText.data()};
+    while (rules.seek_field()) {
+        const std::uint32_t soldHash = rules.read_hex();
+        const std::uint32_t grantHash = rules.read_hex();
+        if (soldHash != sold.definitionHash) {
+            continue;
+        }
+        state::build_data::items::Definition replacement{};
+        const bool resolved =
+            state::build_data::find_item_definition_hash(grantHash, replacement);
+        if (resolved) {
+            substituteIndex = replacement.definitionIndex;
+        }
+        if (resolved) {
+            core::log::writef(core::log::Channel::server,
+                              core::log::Level::info,
+                              "ev=vendor stage=substitute sold=0x%08X granted=0x%08X item=%u",
+                              sold.definitionHash,
+                              replacement.definitionHash,
+                              static_cast<unsigned>(replacement.definitionIndex));
+            return Substitution::replaced;
+        }
+        core::log::writef(core::log::Channel::server,
+                          core::log::Level::warn,
+                          "ev=vendor stage=substitute result=fail reason=missing sold=0x%08X "
+                          "named=0x%08X",
+                          sold.definitionHash,
+                          grantHash);
+        return Substitution::broken;
+    }
+    return Substitution::none;
+}
 
+/**
+ * Rolls one random unheld repeatable bounty, for a row that offers "Additional Bounties".
+ *
+ * The row sells a Dummy placeholder; what it owes is a REPEATABLE bounty, a distinct kind a
+ * character may hold five of. The pool is authored by hash in `vendor_bounty_roll.txt`, because a
+ * repeatable is not a sale row - no vendor in the manifest lists one - so nothing on the vendor can
+ * be discovered or picked from. Rules are keyed by vendor definition hash and trigger category,
+ * since one vendor can own several such rows (Eva Levante has one per event), and lines sharing a
+ * key accumulate. A hash this build does not carry is skipped, so a pool authored from a newer
+ * manifest degrades to what exists rather than failing whole.
+ *
+ * @param vendorIndex Vendor the purchase names.
+ * @param categoryIndex Category of the purchased row, from sale row +100.
+ * @param rolledItemIndex Receives the bounty to grant.
+ * @return True when this row is a bounty roll and its own item must NOT be granted.
+ */
+[[nodiscard]] bool roll_vendor_bounty(std::int32_t vendorIndex,
+                                      std::int32_t categoryIndex,
+                                      std::uint16_t& rolledItemIndex) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    rolledItemIndex = kUnavailableDefinitionIndex;
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (categoryIndex < 0 || !find_vendor(vendorIndex, entry, definition)) {
+        return false;
+    }
+    if (!core::path::read_artifact_text(L"vendor_bounty_roll.txt", g_ruleText)) {
+        return false;
+    }
+    // Every hash authored for this exact key. Lines carrying the same key accumulate, so the pool
+    // is gathered from the whole file rather than from the first line that matches.
+    std::array<std::uint32_t, kRepeatablePoolCapacity> pool{};
+    std::size_t poolCount = 0;
+    core::rule_text::Cursor rules{g_ruleText.data()};
+    while (rules.seek_field()) {
+        const std::uint32_t ruleHash = rules.read_hex();
+        const std::int32_t ruleCategory = rules.read_decimal();
+        const bool wanted = ruleHash == entry.definitionHash && ruleCategory == categoryIndex;
+        // The rest of the line is item hashes. A newline is not a rule field, so this stops at the
+        // end of the line without needing to look for one.
+        while (rules.at_field()) {
+            const std::uint32_t itemHash = rules.read_hex();
+            if (wanted && poolCount < pool.size()) {
+                pool[poolCount++] = itemHash;
+            }
+        }
+    }
+    if (poolCount == 0) {
+        return false;
+    }
+    // Reservoir pick over what this build actually carries and the character does not already hold,
+    // so the pool is walked once and no count is needed up front.
+    std::uint32_t resolved = 0;
+    std::uint32_t held = 0;
+    std::uint32_t candidates = 0;
+    std::uint64_t seed =
+        static_cast<std::uint64_t>(std::chrono::steady_clock::now().time_since_epoch().count());
+    // One account view for the whole pool. Reading it copies the whole account, and the pool is
+    // walked candidate by candidate, so taking it per candidate would copy it dozens of times to
+    // answer dozens of questions about the same unchanging view.
+    const state::AccountState account = state::account_snapshot();
+    for (std::size_t at = 0; at < poolCount; ++at) {
+        state::build_data::items::Definition item{};
+        if (!state::build_data::items::find_hash(pool[at], item)) {
+            continue;
+        }
+        ++resolved;
+        if (state::account::holds_pursuit(account, item.definitionIndex)) {
+            ++held;
+            continue;
+        }
+        ++candidates;
+        seed = (seed * 6364136223846793005ULL) + 1442695040888963407ULL;
+        if ((seed >> 33) % candidates == 0) {
+            rolledItemIndex = item.definitionIndex;
+        }
+    }
+    // Retail lets a character keep five of a vendor's repeatables at once. Refusing here rather
+    // than at the grant keeps the roll from consuming a pick it would only have to throw away.
+    if (held >= kRepeatableHoldLimit) {
+        rolledItemIndex = kUnavailableDefinitionIndex;
+    }
+    core::log::writef(core::log::Channel::server,
+                      core::log::Level::info,
+                      "ev=bounty_roll stage=pick vendor=%d hash=0x%08X category=%d authored=%u "
+                      "resolved=%u held=%u pool=%u item=%d",
+                      vendorIndex,
+                      entry.definitionHash,
+                      categoryIndex,
+                      static_cast<unsigned>(poolCount),
+                      resolved,
+                      held,
+                      candidates,
+                      rolledItemIndex == kUnavailableDefinitionIndex
+                          ? -1
+                          : static_cast<int>(rolledItemIndex));
+    return true;
+}
+
+/**
+ * Runs a vendor's recycle row: charges the stack it names and credits what it pays out.
+ *
+ * The Drifter's four Synth Recycling rows take five synths each; Master Rahool's Recycle Shaders
+ * category has one row per shader, 277 of them. The cost is authored in `vendor_exchange.txt`
+ * rather than read off the row, because the sale row's cost-bearing fields are still role-open on
+ * this build; the manifest's row order is this build's (304 rows checked against Lord Shaxx). A
+ * rule is `<vendor> <row> <costItem> <costQuantity>` then `<payoutItem> <payoutQuantity>` pairs.
+ *
+ * @param vendorIndex Vendor the purchase names.
+ * @param rowIndex Sale row the purchase names.
+ * @param mutation Receives the prepared profile-stack change.
+ * @return True when this row was an exchange and its own item must NOT be granted.
+ */
+[[nodiscard]] bool
+exchange_vendor_row(std::int32_t vendorIndex,
+                    std::int32_t rowIndex,
+                    state::PendingProfileItemAcquisition& mutation) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (rowIndex < 0 || !find_vendor(vendorIndex, entry, definition)) {
+        return false;
+    }
+    if (!core::path::read_artifact_text(L"vendor_exchange.txt", g_ruleText)) {
+        return false;
+    }
+    std::uint32_t costHash = 0;
+    std::int32_t costQuantity = 0;
+    std::array<state::ProfileExchangePayout, kExchangePayoutCapacity> payouts{};
+    std::size_t payoutCount = 0;
+    bool matched = false;
+    bool overflowed = false;
+    core::rule_text::Cursor rules{g_ruleText.data()};
+    while (!matched && rules.seek_field()) {
+        const std::uint32_t ruleVendor = rules.read_hex();
+        const std::int32_t ruleRow = rules.read_decimal();
+        const std::uint32_t ruleCost = rules.read_hex();
+        const std::int32_t ruleCostQuantity = rules.read_decimal();
+        // The rest of the line is payout pairs, and every one of them is consumed even past what
+        // can be held. Stopping mid-line would leave the fields that did not fit to be read as the
+        // start of the next rule, turning one over-long rule into a second, invented one.
+        std::array<state::ProfileExchangePayout, kExchangePayoutCapacity> rulePayouts{};
+        std::size_t rulePayoutCount = 0;
+        bool ruleOverflowed = false;
+        while (rules.at_field()) {
+            const std::uint32_t payoutHash = rules.read_hex();
+            const std::int32_t payoutQuantity = rules.read_decimal();
+            if (rulePayoutCount < rulePayouts.size()) {
+                rulePayouts[rulePayoutCount++] = {payoutHash, payoutQuantity};
+            } else {
+                ruleOverflowed = true;
+            }
+        }
+        matched = ruleVendor == entry.definitionHash && ruleRow == rowIndex;
+        if (matched) {
+            overflowed = ruleOverflowed;
+            costHash = ruleCost;
+            costQuantity = ruleCostQuantity;
+            payouts = rulePayouts;
+            payoutCount = rulePayoutCount;
+        }
+    }
+    if (!matched) {
+        return false;
+    }
+    // A matched rule owns the row whatever else it got wrong, because the rule proves the row's
+    // own item is a placeholder and falling through would grant it. A rule naming more payouts
+    // than the change ring can announce, or none at all, is refused whole rather than paid in
+    // part - and the refusal is logged, because a rule that silently does nothing reads exactly
+    // like a rule that was never written.
+    if (overflowed || payoutCount == 0) {
+        core::log::writef(core::log::Channel::server,
+                          core::log::Level::warn,
+                          "ev=vendor_exchange stage=apply result=fail reason=%s vendor=%d "
+                          "hash=0x%08X row=%d payouts=%zu limit=%zu",
+                          overflowed ? "payout_overflow" : "payout_missing",
+                          vendorIndex,
+                          entry.definitionHash,
+                          rowIndex,
+                          payoutCount,
+                          kExchangePayoutCapacity);
+        return true;
+    }
+    const bool applied = state::prepare_vendor_exchange(
+        costHash, costQuantity,
+        std::span<const state::ProfileExchangePayout>{payouts.data(), payoutCount}, mutation);
+    core::log::writef(core::log::Channel::server,
+                      applied ? core::log::Level::info : core::log::Level::warn,
+                      "ev=vendor_exchange stage=apply result=%s vendor=%d hash=0x%08X row=%d "
+                      "cost=0x%08X quantity=%d payouts=%zu",
+                      applied ? "ok" : "fail",
+                      vendorIndex,
+                      entry.definitionHash,
+                      rowIndex,
+                      costHash,
+                      costQuantity,
+                      payoutCount);
+    // Even a refused exchange owns the row. Falling through would grant the Dummy placeholder,
+    // which is the failure this whole path exists to avoid.
+    return true;
+}
+
+/** How one grant ended, so a caller can tell a settled row from a row still owed its item. */
+enum class GrantResult : std::uint8_t {
+    /** The item is prepared for the inventory; the row's offer is answered. */
+    granted,
+    /** The character already holds this pursuit, so the offer was answered some time ago. */
+    alreadyHeld,
+    /** Nothing was granted and nothing was held; the offer still stands. */
+    refused,
+};
+
+/**
+ * Grants one item, given the collectible that owns it and its definition index.
+ *
+ * Split out of `acquire_item` so a vendor purchase reaches the same grant instead of growing a
+ * second acquisition path. The acquisition state is keyed by collectible, so a caller has to arrive
+ * with one; `find_collectible_for_item` is how a purchase gets there.
+ *
+ * @param message Request being answered, for the log line.
+ * @param collectibleIndex Collectible that owns the item.
+ * @param itemDefinitionIndex Item to grant.
+ * @param outcome Receives the prepared mutation on success.
+ * @return How the grant ended, which is what decides whether the row's offer was answered.
+ */
+GrantResult grant_item_definition(const middleware::web_service::Message& message,
+                                  std::uint16_t collectibleIndex,
+                                  std::uint16_t itemDefinitionIndex,
+                                  Outcome& outcome) noexcept {
     state::build_data::items::Definition definition{};
     if (!state::build_data::find_item_definition_index(itemDefinitionIndex, definition)) {
         report_acquisition_preparation(
             message, "fail", "item_definition", collectibleIndex, itemDefinitionIndex, 0, 0);
-        return;
+        return GrantResult::refused;
+    }
+    // The same rule the client's native vendor-row gate applies locally, so a row that is still
+    // offered can never be one this grant would refuse.
+    if (state::account::holds_pursuit(itemDefinitionIndex)) {
+        report_acquisition_preparation(message,
+                                       "fail",
+                                       "already_held",
+                                       collectibleIndex,
+                                       itemDefinitionIndex,
+                                       definition.definitionHash,
+                                       0);
+        return GrantResult::alreadyHeld;
     }
 
     state::build_data::items::details::Definition detail{};
@@ -673,7 +1037,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                        itemDefinitionIndex,
                                        definition.definitionHash,
                                        0);
-        return;
+        return GrantResult::refused;
     }
 
     namespace bucket_domain = state::build_data::inventory::buckets;
@@ -687,7 +1051,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                            itemDefinitionIndex,
                                            definition.definitionHash,
                                            0);
-            return;
+            return GrantResult::refused;
         }
         state::PendingProfileItemAcquisition mutation{};
         if (!state::prepare_profile_item_acquisition(
@@ -699,7 +1063,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                            itemDefinitionIndex,
                                            definition.definitionHash,
                                            0);
-            return;
+            return GrantResult::refused;
         }
         outcome.mutation = mutation;
         report_acquisition_preparation(message,
@@ -709,7 +1073,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                        itemDefinitionIndex,
                                        definition.definitionHash,
                                        0);
-        return;
+        return GrantResult::granted;
     }
     if (bucket.arraySelector != bucket_domain::ArraySelector::character) {
         report_acquisition_preparation(message,
@@ -719,7 +1083,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                        itemDefinitionIndex,
                                        definition.definitionHash,
                                        0);
-        return;
+        return GrantResult::refused;
     }
 
     state::PendingItemAcquisition mutation{};
@@ -731,7 +1095,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                        itemDefinitionIndex,
                                        definition.definitionHash,
                                        0);
-        return;
+        return GrantResult::refused;
     }
     outcome.mutation = mutation;
     report_acquisition_preparation(message,
@@ -741,6 +1105,459 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                                    itemDefinitionIndex,
                                    definition.definitionHash,
                                    mutation.acquiredInstanceSoid);
+    return GrantResult::granted;
+}
+
+/**
+ * Finds the collectible that owns one item definition.
+ *
+ * A sale row names an item, never a collectible, while the acquisition state is keyed by
+ * collectible. Bounties, tokens and quest steps have none at all; those are granted by hash under
+ * `kNoCollectibleIndex`, which is why the caller's sentinel is left in place when nothing matches.
+ *
+ * @param itemDefinitionIndex Item to look up.
+ * @param collectibleIndex Receives the owning collectible row; untouched when none does.
+ * @return True when a collectible names this item.
+ */
+[[nodiscard]] bool find_collectible_for_item(std::uint16_t itemDefinitionIndex,
+                                             std::uint16_t& collectibleIndex) noexcept {
+    return state::build_data::collectibles::find_granting(itemDefinitionIndex, collectibleIndex);
+}
+
+/** Prepares the exact three-byte opcode-1820 Collections item request. */
+void acquire_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
+    middleware::web_service::messages::opcode1820::Request request{};
+    if (!middleware::web_service::messages::opcode1820::parse_request(message, request)) {
+        report_acquisition_preparation(message,
+                                       "fail",
+                                       "payload_bits",
+                                       kUnavailableDefinitionIndex,
+                                       kUnavailableDefinitionIndex,
+                                       0,
+                                       0);
+        return;
+    }
+    const std::uint16_t collectibleIndex = request.collectibleIndex;
+    std::uint16_t itemDefinitionIndex = 0;
+    if (!state::build_data::find_collectible_item_definition_index(collectibleIndex,
+                                                                   itemDefinitionIndex)) {
+        report_acquisition_preparation(message,
+                                       "fail",
+                                       "collectible_definition",
+                                       collectibleIndex,
+                                       kUnavailableDefinitionIndex,
+                                       0,
+                                       0);
+        return;
+    }
+    (void)grant_item_definition(message, collectibleIndex, itemDefinitionIndex, outcome);
+}
+
+/**
+ * Resolves one vendor row to the item it sells.
+ *
+ * Shared by the purchase (901) and the quest acquire (904), which name a row the same way, so the
+ * two cannot drift apart.
+ *
+ * @param vendorIndex Vendor table row.
+ * @param rowIndex Sale row within that vendor.
+ * @param itemDefinitionIndex Receives the item the row sells.
+ * @param reason Receives the step that failed, when one does.
+ * @return True when the row resolved.
+ */
+[[nodiscard]] bool resolve_vendor_row(std::int32_t vendorIndex,
+                                      std::int32_t rowIndex,
+                                      std::uint16_t& itemDefinitionIndex,
+                                      std::int32_t& categoryIndex,
+                                      const char*& reason) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    if (vendorIndex < 0 || rowIndex < 0) {
+        reason = "negative_index";
+        return false;
+    }
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (!find_vendor(vendorIndex, entry, definition)) {
+        reason = "vendor";
+        return false;
+    }
+    vendor_domain::SaleRow row{};
+    if (!vendor_domain::sale_row(definition, static_cast<std::size_t>(rowIndex), row)) {
+        reason = "sale_row";
+        return false;
+    }
+    itemDefinitionIndex = row.itemIndex;
+    categoryIndex = row.categoryIndex;
+    return true;
+}
+
+/** Pursuit rows written out when a vendor is asked what it actually sells. */
+constexpr std::size_t kPursuitListCap = 64;
+
+/**
+ * Lists the sale rows of one vendor whose item is a pursuit, when a rowless tile fails to resolve.
+ *
+ * It says what this vendor does offer that would land in the Quests tab, which is the difference
+ * between "this click is broken" and "this click was never a quest". Items rather than rows, because
+ * one placeholder repeats across dozens of rows. The classification is the shared pursuit rule.
+ *
+ * @param vendorIndex Vendor to list.
+ */
+void report_pursuit_rows(std::int32_t vendorIndex) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    namespace detail_domain = state::build_data::items::details;
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (!find_vendor(vendorIndex, entry, definition)) {
+        return;
+    }
+    const std::size_t count = definition.saleCount;
+    // One item repeats across dozens of rows - Amanda declares 38 consecutive rows of a single
+    // placeholder - so listing rows rather than items buries everything interesting under filler.
+    static std::array<std::uint16_t, kPursuitListCap> seen{};
+    std::size_t listed = 0;
+    std::size_t pursuits = 0;
+    for (std::size_t row = 0; row < count; ++row) {
+        vendor_domain::SaleRow sale{};
+        if (!vendor_domain::sale_row(definition, row, sale)) {
+            break;
+        }
+        const std::uint16_t itemIndex = sale.itemIndex;
+        detail_domain::Definition detail{};
+        if (!state::build_data::find_configured_item_detail(itemIndex, detail)
+            || detail.equipmentSlot.has_value() || detail.maxStackSize > 1) {
+            continue;
+        }
+        ++pursuits;
+        bool duplicate = false;
+        for (std::size_t index = 0; index < listed; ++index) {
+            duplicate = duplicate || seen[index] == itemIndex;
+        }
+        if (duplicate || listed >= kPursuitListCap) {
+            continue;
+        }
+        seen[listed] = itemIndex;
+        ++listed;
+        // One line per distinct row, so this is the detail behind the summary rather than
+        // something worth putting in front of everything else that reports at info.
+        core::log::writef(core::log::Channel::server,
+                          core::log::Level::debug,
+                          "ev=vendor stage=pursuit vendor=%d sale=%zu item=%u hash=0x%08X "
+                          "bucket=%u",
+                          static_cast<int>(vendorIndex),
+                          row,
+                          static_cast<unsigned>(itemIndex),
+                          detail.definitionHash,
+                          static_cast<unsigned>(detail.bucketId));
+    }
+    core::log::writef(core::log::Channel::server,
+                      core::log::Level::info,
+                      "ev=vendor stage=pursuits vendor=%d sale_rows=%zu pursuits=%zu "
+                      "distinct_listed=%zu",
+                      static_cast<int>(vendorIndex),
+                      count,
+                      pursuits,
+                      listed);
+}
+
+/** An installed row names its item by definition hash at this offset. */
+constexpr std::size_t kInstalledRowHashOffset = 0;
+/** FNV-1's basis, which this engine also uses as its absent-hash sentinel. */
+constexpr std::uint32_t kAbsentNameHash = 0x811C9DC5U;
+
+/**
+ * Resolves the item behind a 904 that names no sale row.
+ *
+ * Amanda Holliday's Legacy Content tiles send `slot=1, row=-1`, so the slot is all that identifies
+ * them - and it indexes the installed array: the Red War tile's vendor declares 220 sale rows but
+ * 22 installed rows, and its slot is 1. That installed row carries the item's definition hash at
+ * `+0`, where a sale row names its item by index. The resolution is logged either way, because a
+ * wrong item that commits cleanly is harder to spot than a refusal.
+ *
+ * @param vendorIndex Vendor the request named.
+ * @param slotIndex The 16-bit slot field, which is all the request carries.
+ * @param itemDefinitionIndex Receives the item, or the unavailable sentinel.
+ * @return True when the row's hash resolved to an installed item definition.
+ */
+[[nodiscard]] bool resolve_rowless_quest(std::int32_t vendorIndex,
+                                         std::int32_t slotIndex,
+                                         std::uint16_t& itemDefinitionIndex) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    itemDefinitionIndex = kUnavailableDefinitionIndex;
+    vendor_domain::IndexEntry entry{};
+    vendor_domain::Definition definition{};
+    if (slotIndex < 0 || !find_vendor(vendorIndex, entry, definition)) {
+        return false;
+    }
+    vendor_domain::InstalledRow installed{};
+    if (!vendor_domain::installed_row(definition, static_cast<std::size_t>(slotIndex), installed)) {
+        return false;
+    }
+    const auto& raw = installed.raw;
+    std::uint32_t definitionHash = 0;
+    std::memcpy(&definitionHash, raw.data() + kInstalledRowHashOffset, sizeof definitionHash);
+
+    state::build_data::items::Definition item{};
+    const bool resolved = definitionHash != kAbsentNameHash
+                          && state::build_data::find_item_definition_hash(definitionHash, item);
+    if (resolved) {
+        itemDefinitionIndex = item.definitionIndex;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=ws904 stage=rowless vendor=%d slot=%d installed=%u sale=%u "
+                                "third=%u hash=0x%08X item=%u resolved=%u hex=",
+                                static_cast<int>(vendorIndex),
+                                static_cast<int>(slotIndex),
+                                static_cast<unsigned>(definition.installedCount),
+                                static_cast<unsigned>(definition.saleCount),
+                                static_cast<unsigned>(definition.thirdCount),
+                                definitionHash,
+                                static_cast<unsigned>(itemDefinitionIndex),
+                                resolved ? 1U : 0U);
+    if (written > 0 && static_cast<std::size_t>(written) < line.size()) {
+        std::size_t length = static_cast<std::size_t>(written);
+        const auto* const bytes = reinterpret_cast<const std::byte*>(raw.data());
+        (void)core::log::append_hex(line, length, {bytes, raw.size()});
+        if (length != 0) {
+            core::log::write(core::log::Channel::server,
+                             resolved ? core::log::Level::info : core::log::Level::warn,
+                             {line.data(), length});
+        }
+    }
+    return resolved;
+}
+
+/** What one resolved vendor row turned out to be, once it was settled. */
+enum class RowOutcome : std::uint8_t {
+    /** The row rolled a bounty from an authored pool. */
+    bountyRoll,
+    /** The row charged one stack and credited others. */
+    exchange,
+    /** The row's item is prepared for the inventory; its offer is answered once that commits. */
+    granted,
+    /** The character already holds the row's pursuit, so its offer was answered some time ago. */
+    alreadyHeld,
+    /** The row should have granted and could not, so its offer still stands. */
+    grantRefused,
+};
+
+/**
+ * Settles one resolved vendor row, in the order a row's behaviours are tried.
+ *
+ * Both vendor opcodes end here. A row is a bounty roll, an exchange, or a grant, and which cannot
+ * be read off the row itself: each is recognised by an authored rule keyed to the vendor, tried in
+ * turn, and the first that claims the row owns it. One ordered chain is what keeps 901 and 904 from
+ * drifting apart.
+ *
+ * @param message Request being answered.
+ * @param opcode Opcode to report under.
+ * @param vendorIndex Vendor 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 outcome Receives whatever mutation the row prepared.
+ * @return What the row turned out to be.
+ */
+RowOutcome settle_vendor_row(const middleware::web_service::Message& message,
+                             std::uint16_t opcode,
+                             std::int32_t vendorIndex,
+                             std::int32_t rowIndex,
+                             std::int32_t categoryIndex,
+                             std::uint16_t itemDefinitionIndex,
+                             Outcome& outcome) noexcept {
+    std::uint16_t rolledBounty = kUnavailableDefinitionIndex;
+    if (roll_vendor_bounty(vendorIndex, categoryIndex, rolledBounty)) {
+        report_purchase(opcode,
+                        "ok",
+                        rolledBounty == kUnavailableDefinitionIndex ? "bounty_pool_empty"
+                                                                   : "bounty_roll",
+                        vendorIndex,
+                        rowIndex,
+                        itemDefinitionIndex);
+        if (rolledBounty != kUnavailableDefinitionIndex) {
+            std::uint16_t rolledCollectible = state::build_data::collectibles::kNoCollectibleIndex;
+            (void)find_collectible_for_item(rolledBounty, rolledCollectible);
+            (void)grant_item_definition(message, rolledCollectible, rolledBounty, outcome);
+        }
+        return RowOutcome::bountyRoll;
+    }
+    state::PendingProfileItemAcquisition exchange{};
+    if (exchange_vendor_row(vendorIndex, rowIndex, exchange)) {
+        report_purchase(opcode, "ok", "exchange", vendorIndex, rowIndex, itemDefinitionIndex);
+        if (exchange.prepared) {
+            outcome.mutation = exchange;
+        }
+        return RowOutcome::exchange;
+    }
+    // A placeholder row grants what it stands for, not the placeholder: a Dummy item put in the
+    // Quests bucket is one the client will not draw, and the row never settles because the player
+    // never receives what it offered.
+    std::uint16_t granted = itemDefinitionIndex;
+    std::uint16_t substituteIndex = kUnavailableDefinitionIndex;
+    switch (substitute_for_item(granted, substituteIndex)) {
+    case Substitution::replaced:
+        granted = substituteIndex;
+        break;
+    case Substitution::broken:
+        // The rule proves the row's item is a placeholder, so granting it would be the wrong
+        // grant this path exists to prevent. The rule itself already logged what is missing.
+        report_purchase(opcode, "fail", "substitute_missing", vendorIndex, rowIndex, granted);
+        return RowOutcome::grantRefused;
+    case Substitution::none:
+        break;
+    }
+    std::uint16_t collectibleIndex = state::build_data::collectibles::kNoCollectibleIndex;
+    const bool collected = find_collectible_for_item(granted, collectibleIndex);
+    report_purchase(opcode,
+                    "ok",
+                    collected ? "resolved" : "resolved_no_collectible",
+                    vendorIndex,
+                    rowIndex,
+                    granted);
+    // A grant that failed for a transient reason - the loadout would not resolve, the bucket was
+    // full - leaves the row's offer standing, and the caller must not treat it as answered.
+    switch (grant_item_definition(message, collectibleIndex, granted, outcome)) {
+    case GrantResult::granted:
+        return RowOutcome::granted;
+    case GrantResult::alreadyHeld:
+        return RowOutcome::alreadyHeld;
+    case GrantResult::refused:
+        break;
+    }
+    return RowOutcome::grantRefused;
+}
+
+/**
+ * Prepares one opcode-904 quest acquire.
+ *
+ * A quest names a vendor row exactly as a purchase does, and the item behind it is granted through
+ * the same path, so a quest lands in the inventory the way a bounty now does.
+ */
+void acquire_quest(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
+    namespace quest = middleware::web_service::messages::opcode904;
+    quest::Request request{};
+    if (!quest::parse_request(message, request)) {
+        report_purchase(quest::kOpcode, "fail", "payload", -1, -1, kUnavailableDefinitionIndex);
+        return;
+    }
+    // The 16-bit slot field is where the click landed, and indexing sale rows with it granted
+    // armour mods. The 32-bit field is the real row; a body without one has never been captured,
+    // and guessing the slot in as a sale row would reproduce that exact wrong grant - so it is
+    // refused, and the refusal names the shape so a real capture can settle it.
+    if (!request.hasSaleIndex) {
+        report_purchase(quest::kOpcode,
+                        "fail",
+                        "sale_field_missing",
+                        request.vendorIndex,
+                        request.slotIndex,
+                        kUnavailableDefinitionIndex);
+        return;
+    }
+    const std::int32_t row = request.saleIndex;
+    std::uint16_t itemDefinitionIndex = 0;
+    const char* reason = "unknown";
+    // A row of -1 is the client saying this tile is not a sale row at all, rather than a row that
+    // failed to resolve, so it takes the installed array instead. Falling back to the slot as a
+    // sale row would grant whatever sits there, which is the wrong-item bug that made quests hand
+    // out armour mods.
+    const bool rowless = row < 0;
+    // A rowless 904 is an interaction reply rather than a purchase, and the rank-up reward tile is
+    // one: its reply names no sale row, so the slot field is the interaction it answered.
+    std::int32_t questCategoryIndex = -1;
+    const bool located =
+        rowless ? resolve_rowless_quest(request.vendorIndex, request.slotIndex, itemDefinitionIndex)
+                : resolve_vendor_row(request.vendorIndex, row, itemDefinitionIndex,
+                                    questCategoryIndex, reason);
+    if (!located) {
+        report_purchase(quest::kOpcode,
+                        "fail",
+                        rowless ? "rowless_unresolved" : reason,
+                        request.vendorIndex,
+                        row,
+                        kUnavailableDefinitionIndex);
+        // A tile that names no row grants nothing, so say what this vendor does offer that would
+        // land in the Quests tab. That is the difference between "this click is broken" and "this
+        // click was never a quest".
+        if (rowless) {
+            report_pursuit_rows(request.vendorIndex);
+        }
+        return;
+    }
+    const RowOutcome settled = settle_vendor_row(message,
+                                                 quest::kOpcode,
+                                                 request.vendorIndex,
+                                                 row,
+                                                 questCategoryIndex,
+                                                 itemDefinitionIndex,
+                                                 outcome);
+    // The banner that offered this quest is answered only by a row whose offer is answered, and
+    // nothing else tells the client so: its picker keeps choosing the same interaction for as long
+    // as the quest is offerable. A bounty roll and an exchange leave the banner's own question
+    // unanswered, and a refused grant still owes the player its quest.
+    if (request.vendorIndex < 0
+        || request.vendorIndex >= static_cast<std::int32_t>(state::vendors::kVendorCapacity)) {
+        return;
+    }
+    const auto vendor = static_cast<std::uint16_t>(request.vendorIndex);
+    switch (settled) {
+    case RowOutcome::alreadyHeld:
+        // Answered some time ago, and nothing is left to commit, so the banner retires now. This
+        // is the re-click on a quest already in the tab.
+        (void)state::vendors::answer_shown(vendor);
+        break;
+    case RowOutcome::granted:
+        // Prepared, not committed. The answer rides the transaction and is written where the
+        // grant commits, so a mutation dropped on the way never buries a quest still owed.
+        outcome.answeredVendor = vendor;
+        break;
+    case RowOutcome::bountyRoll:
+    case RowOutcome::exchange:
+    case RowOutcome::grantRefused:
+        break;
+    }
+}
+
+/**
+ * Prepares one opcode-901 vendor purchase, for any Tower vendor.
+ *
+ * The request names a vendor row and a sale row. The sale row names an item-definition index, which
+ * is the same thing a Collections pull resolves its collectible to, so this resolves the row and
+ * hands over to the very same grant.
+ *
+ * Cost is deliberately not charged: the sale row's cost-bearing fields are still role-open, and the
+ * domain header warns against naming one a cost without its mutation reader.
+ */
+void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
+    namespace purchase = middleware::web_service::messages::opcode901;
+    purchase::Request request{};
+    if (!purchase::parse_request(message, request)) {
+        report_purchase(purchase::kOpcode, "fail", "payload", -1, -1, kUnavailableDefinitionIndex);
+        return;
+    }
+    std::uint16_t itemDefinitionIndex = 0;
+    const char* reason = "unknown";
+    std::int32_t categoryIndex = -1;
+    if (!resolve_vendor_row(
+            request.vendorIndex, request.saleIndex, itemDefinitionIndex, categoryIndex, reason)) {
+        report_purchase(purchase::kOpcode,
+                        "fail",
+                        reason,
+                        request.vendorIndex,
+                        request.saleIndex,
+                        kUnavailableDefinitionIndex);
+        return;
+    }
+    // Bounties, quest steps and tokens carry no collectible. The acquisition takes the sentinel
+    // rather than a made-up row, and both prepare and commit skip the collectible steps for it.
+    (void)settle_vendor_row(message,
+                            purchase::kOpcode,
+                            request.vendorIndex,
+                            request.saleIndex,
+                            categoryIndex,
+                            itemDefinitionIndex,
+                            outcome);
 }
 
 } // namespace sunrise::server::web_service

+ 2 - 0
Sunrise/src/server/web_service/web_service_actions.h

@@ -22,5 +22,7 @@ void mutate_item_state(const middleware::web_service::Message& message, Outcome&
 mutate_settings(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 void dismantle_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 void acquire_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+void acquire_quest(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 
 } // namespace sunrise::server::web_service

+ 7 - 52
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -26,6 +26,7 @@
 #include "../../middleware/web_service/messages/opcode702.h"
 #include "../../middleware/web_service/messages/opcode801.h"
 #include "../../middleware/web_service/messages/opcode901/opcode901_codec.h"
+#include "../../middleware/web_service/messages/opcode904/opcode904_codec.h"
 #include "../../middleware/web_service/messages/opcode903.h"
 #include "../../middleware/web_service/web_service_envelope.h"
 #include "../../state/account/account_state.h"
@@ -43,8 +44,6 @@ namespace messages = middleware::web_service::messages;
 
 /** One ordinary event line carries an opcode and its fixed prefix. */
 constexpr std::size_t kOpcodeLineCapacity = 64;
-/** One refusal line carries both request indices, the clock presence, and the clock verdict. */
-constexpr std::size_t kPurchaseLineCapacity = 128;
 /** A request trace keeps enough payload to identify an item-action descriptor. */
 constexpr std::size_t kRequestPayloadTraceBytes = 192;
 /** Marks a trace that stopped at the cap, so a short hex string is not read as a short payload. */
@@ -81,51 +80,6 @@ void report_request(const middleware::web_service::Message& message) noexcept {
     core::log::write(core::log::Channel::server, core::log::Level::info, {line.data(), length});
 }
 
-/**
- * Refuses one vendor purchase and answers it.
- * No award, cost or stock rule exists yet, so no purchase can succeed. The refusal must still be
- * answered, because no answer holds the head of the client's pending queue.
- * @param message Parsed purchase request.
- * @param response Response-body storage owned by the caller.
- * @param written Receives the encoded response size.
- * @return True when the refusal was encoded.
- */
-[[nodiscard]] bool refuse_purchase(const middleware::web_service::Message& message,
-                                   std::span<std::byte> response,
-                                   std::size_t& written) noexcept {
-    messages::opcode901::Request purchase;
-    const bool parsed = messages::opcode901::parse_request(message, purchase);
-    // The clock verdict is logged, never acted on. Nothing can pass while the route refuses.
-    const auto policy =
-        messages::opcode901::check_clock(purchase, core::runtime::server_clock_seconds());
-    std::array<char, kPurchaseLineCapacity> line{};
-    const int length =
-        parsed ? std::snprintf(
-                     line.data(),
-                     line.size(),
-                     "ev=ws901 stage=purchase result=refuse vendor=%d sale=%d present=%u policy=%s",
-                     static_cast<int>(purchase.vendorIndex),
-                     static_cast<int>(purchase.saleIndex),
-                     purchase.hasClock ? 1U : 0U,
-                     messages::opcode901::clock_policy_name(policy))
-               : std::snprintf(line.data(),
-                               line.size(),
-                               "ev=ws901 stage=purchase result=refuse reason=parse");
-    report_line(core::log::Level::error, line, length);
-    middleware::web_service::StatusResponse status{};
-    status.code = middleware::web_service::kRefusedStatusCode;
-    // A refused purchase grants nothing, so no Family-4 revision carries its result.
-    status.value = middleware::web_service::kNoFamily4Publication;
-    // The trailing bool drives a local action effect on the client, so it stays clear.
-    status.trailingBool = false;
-    return middleware::web_service::encode_response(
-        message,
-        middleware::web_service::ResponseShape::statusPairWithBool,
-        status,
-        response,
-        written);
-}
-
 /**
  * Answers a request whose own codec refused with the bare correlated echo.
  * The Client matches on the echoed transaction id. A missing body under-runs its decoder and
@@ -275,11 +229,8 @@ bool consume(std::span<const std::byte> request,
                || encode_echo(message, response, written);
     }
 
-    // Runs before the shared response-shape path, which would answer the success status.
-    if (message.opcode == messages::opcode901::kOpcode) {
-        return refuse_purchase(message, response, written)
-               || encode_echo(message, response, written);
-    }
+    // Vendor purchases fall through to the shared response-shape path, which runs the action and
+    // answers its status: an action that prepared no mutation is answered with the refused code.
 
     if (message.opcode == messages::opcode601::kOpcode) {
         return messages::opcode601::encode_response(message, response, written)
@@ -317,6 +268,10 @@ bool consume(std::span<const std::byte> request,
         acceptedWithoutMutation = disposition == state::SettingsUpdateDisposition::acceptedNoChange;
     } else if (message.opcode == messages::opcode1820::kOpcode) {
         acquire_item(message, outcome);
+    } else if (message.opcode == middleware::web_service::messages::opcode901::kOpcode) {
+        purchase_item(message, outcome);
+    } else if (message.opcode == middleware::web_service::messages::opcode904::kOpcode) {
+        acquire_quest(message, outcome);
     } else {
         dispatched = false;
     }