|
|
@@ -4,10 +4,12 @@
|
|
|
|
|
|
#include <algorithm>
|
|
|
#include <array>
|
|
|
+#include <bit>
|
|
|
#include <cstddef>
|
|
|
#include <cstdint>
|
|
|
#include <cstdio>
|
|
|
#include <limits>
|
|
|
+#include <optional>
|
|
|
#include <string_view>
|
|
|
#include <utility>
|
|
|
|
|
|
@@ -17,6 +19,7 @@
|
|
|
#include "runtime.h"
|
|
|
#include "state.h"
|
|
|
#include "state_account_transaction_helpers.h"
|
|
|
+#include "state_rolled_socket_plugs.h"
|
|
|
#include "storage/internal.h"
|
|
|
|
|
|
namespace sunrise::state {
|
|
|
@@ -27,9 +30,6 @@ namespace item_details = build_data::items::details;
|
|
|
namespace inventory_buckets = build_data::inventory::buckets;
|
|
|
namespace family4_loadout = middleware::datagen::family4::loadout;
|
|
|
|
|
|
-/** Equipment slots 0-2 are weapons and 3-7 are class-specific armor. */
|
|
|
-constexpr std::uint8_t kGearEquipmentSlotCount = 8;
|
|
|
-
|
|
|
/** Writes one exhaustive item-dismantle transaction checkpoint. */
|
|
|
void report_dismantle(std::string_view stage,
|
|
|
std::string_view result,
|
|
|
@@ -70,16 +70,234 @@ void report_dismantle(std::string_view stage,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/** Records how the dismantled item was classified and how many payout materials matched. */
|
|
|
+void report_dismantle_reward_match(std::uint32_t definitionHash,
|
|
|
+ std::uint8_t tier,
|
|
|
+ std::uint8_t gearClass,
|
|
|
+ bool isMasterworked,
|
|
|
+ std::size_t materialCount) noexcept {
|
|
|
+ std::array<char, core::log::kLineCapacity> line{};
|
|
|
+ const int count = std::snprintf(line.data(),
|
|
|
+ line.size(),
|
|
|
+ "ev=dismantle stage=reward result=ok reason=matched "
|
|
|
+ "definition_hash=0x%08X tier=%u gear_class=%u masterworked=%u "
|
|
|
+ "materials=%zu",
|
|
|
+ definitionHash,
|
|
|
+ static_cast<unsigned>(tier),
|
|
|
+ static_cast<unsigned>(gearClass),
|
|
|
+ isMasterworked ? 1U : 0U,
|
|
|
+ materialCount);
|
|
|
+ if (count > 0) {
|
|
|
+ core::log::write(core::log::Channel::state,
|
|
|
+ core::log::Level::debug,
|
|
|
+ {line.data(), static_cast<std::size_t>(count)});
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Records one payout row that could not be credited, so a silent zero payout is visible. */
|
|
|
+void report_dismantle_reward_dropped(std::string_view reason,
|
|
|
+ std::uint32_t definitionHash,
|
|
|
+ std::int32_t policyQuantity,
|
|
|
+ std::int32_t previousQuantity,
|
|
|
+ std::int32_t maxStackSize) noexcept {
|
|
|
+ std::array<char, core::log::kLineCapacity> line{};
|
|
|
+ const int count = std::snprintf(line.data(),
|
|
|
+ line.size(),
|
|
|
+ "ev=dismantle stage=reward result=dropped reason=%.*s "
|
|
|
+ "definition_hash=0x%08X policy_quantity=%d held=%d "
|
|
|
+ "max_stack=%d",
|
|
|
+ static_cast<int>(reason.size()),
|
|
|
+ reason.data(),
|
|
|
+ definitionHash,
|
|
|
+ policyQuantity,
|
|
|
+ previousQuantity,
|
|
|
+ maxStackSize);
|
|
|
+ if (count > 0) {
|
|
|
+ core::log::write(core::log::Channel::state,
|
|
|
+ core::log::Level::warn,
|
|
|
+ {line.data(), static_cast<std::size_t>(count)});
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @return The gear class one native equipment slot belongs to, or 0 outside the gear the
|
|
|
+ * supported client pays materials for. Native slot numbers are not the semantic enum
|
|
|
+ * order (kinetic is 7, energy 8, heavy 9), so the check goes through the semantic map.
|
|
|
+ */
|
|
|
+[[nodiscard]] std::uint8_t gear_class_of(std::uint8_t nativeSlot) noexcept {
|
|
|
+ using EquipmentSlot = authored_inventory::EquipmentSlot;
|
|
|
+ std::size_t semanticIndex = authored_inventory::kEquipmentSlotCount;
|
|
|
+ if (!semantic_equipment_slot(nativeSlot, semanticIndex)) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ switch (static_cast<EquipmentSlot>(semanticIndex)) {
|
|
|
+ case EquipmentSlot::kinetic:
|
|
|
+ case EquipmentSlot::energy:
|
|
|
+ case EquipmentSlot::heavy:
|
|
|
+ return static_cast<std::uint8_t>(DismantleGearClass::weapon);
|
|
|
+ case EquipmentSlot::helmet:
|
|
|
+ case EquipmentSlot::gauntlets:
|
|
|
+ case EquipmentSlot::chest:
|
|
|
+ case EquipmentSlot::legs:
|
|
|
+ case EquipmentSlot::classItem:
|
|
|
+ return static_cast<std::uint8_t>(DismantleGearClass::armor);
|
|
|
+ default:
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Per-stat-row tally of one lane's pool, enough to recognise a masterwork tier ladder. */
|
|
|
+struct LadderTally {
|
|
|
+ static constexpr std::size_t kRowCount = 256;
|
|
|
+ static constexpr std::size_t kValueBits = 64;
|
|
|
+ std::array<std::uint16_t, kRowCount> members{};
|
|
|
+ std::array<std::int32_t, kRowCount> greatest{};
|
|
|
+ /** Bit per distinct value below kValueBits; a larger value marks the row unusable. */
|
|
|
+ std::array<std::uint64_t, kRowCount> seen{};
|
|
|
+ std::array<bool, kRowCount> overflow{};
|
|
|
+};
|
|
|
+
|
|
|
+/** Folds one pool member's stats into the tally. */
|
|
|
+bool tally_member(void* context, std::uint16_t plugIndex) noexcept {
|
|
|
+ auto& tally = *static_cast<LadderTally*>(context);
|
|
|
+ item_details::Definition detail{};
|
|
|
+ if (!build_data::find_configured_item_detail(plugIndex, detail)
|
|
|
+ || detail.statCount > detail.stats.size()) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ for (std::size_t index = 0; index < detail.statCount; ++index) {
|
|
|
+ const item_details::Stat& stat = detail.stats[index];
|
|
|
+ ++tally.members[stat.row];
|
|
|
+ tally.greatest[stat.row] = (std::max)(tally.greatest[stat.row], stat.value);
|
|
|
+ if (stat.value < 0 || stat.value >= static_cast<std::int32_t>(LadderTally::kValueBits)) {
|
|
|
+ tally.overflow[stat.row] = true;
|
|
|
+ } else {
|
|
|
+ tally.seen[stat.row] |= 1ULL << static_cast<unsigned>(stat.value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @return True when the plug in one lane sits high enough on that lane's masterwork ladder.
|
|
|
+ *
|
|
|
+ * A masterwork lane's pool is a ladder: its tier plugs all carry the same stat row, each with
|
|
|
+ * a different value, one per tier. Any lane whose pool has such a row for the plug's stats is a
|
|
|
+ * ladder; a mod pool is not, because many mods repeat the same value. A weapon counts only at
|
|
|
+ * the top of its ladder; armor counts from halfway up, which is where the service started
|
|
|
+ * refunding materials.
|
|
|
+ */
|
|
|
+[[nodiscard]] bool on_masterwork_ladder(const item_details::Definition& target,
|
|
|
+ std::uint8_t lane,
|
|
|
+ const item_details::Definition& plug,
|
|
|
+ bool weapon) noexcept {
|
|
|
+ constexpr std::uint16_t kMinimumLadderRungs = 3;
|
|
|
+ if (plug.statCount == 0 || plug.statCount > plug.stats.size()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ LadderTally tally{};
|
|
|
+ if (!build_data::visit_socket_plug_pool(target.definitionIndex, lane, &tally_member, &tally)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // The ladder row is the plug's stat row most of the pool shares.
|
|
|
+ std::size_t ladderRow = LadderTally::kRowCount;
|
|
|
+ for (std::size_t index = 0; index < plug.statCount; ++index) {
|
|
|
+ const std::uint8_t row = plug.stats[index].row;
|
|
|
+ if (ladderRow == LadderTally::kRowCount || tally.members[row] > tally.members[ladderRow]) {
|
|
|
+ ladderRow = row;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (ladderRow >= LadderTally::kRowCount || tally.overflow[ladderRow]
|
|
|
+ || tally.members[ladderRow] < kMinimumLadderRungs
|
|
|
+ || std::popcount(tally.seen[ladderRow]) != tally.members[ladderRow]) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ std::int32_t value = 0;
|
|
|
+ for (std::size_t index = 0; index < plug.statCount; ++index) {
|
|
|
+ if (plug.stats[index].row == ladderRow) {
|
|
|
+ value = plug.stats[index].value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const std::int32_t top = tally.greatest[ladderRow];
|
|
|
+ return weapon ? value == top : value * 2 >= top;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @return True when the item is masterworked for the payout: a lane holds a rolled result plug
|
|
|
+ * (a Year-1 masterwork), or a plug high enough on its lane's tier ladder. An item still
|
|
|
+ * on its native defaults is read through the definition's initial plugs, since
|
|
|
+ * default-equipped gear can ship masterworked.
|
|
|
+ */
|
|
|
+[[nodiscard]] bool masterworked(const authored_inventory::Item& item,
|
|
|
+ const item_details::Definition& detail,
|
|
|
+ bool weapon) noexcept {
|
|
|
+ const bool authored = item.sockets.policy == authored_inventory::SocketPolicy::authored;
|
|
|
+ for (std::size_t lane = 0;
|
|
|
+ lane < detail.ordinarySocketCount && lane < authored_inventory::kPlugCapacity;
|
|
|
+ ++lane) {
|
|
|
+ build_data::items::Definition plug{};
|
|
|
+ if (authored) {
|
|
|
+ const std::optional<std::uint32_t>& hash = item.sockets.plugs[lane];
|
|
|
+ if (!hash.has_value() || !build_data::find_item_definition_hash(*hash, plug)
|
|
|
+ || plug.definitionHash != *hash) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const std::uint16_t plugIndex = detail.initialPlugIndices[lane];
|
|
|
+ if (plugIndex == item_details::kUnavailableItemIndex
|
|
|
+ || !build_data::find_item_definition_index(plugIndex, plug)
|
|
|
+ || plug.definitionIndex != plugIndex) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (is_rolled_result(plug.definitionHash)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ item_details::Definition plugDetail{};
|
|
|
+ if (build_data::find_configured_item_detail(plug.definitionIndex, plugDetail)
|
|
|
+ && plugDetail.definitionHash == plug.definitionHash
|
|
|
+ && on_masterwork_ladder(detail, static_cast<std::uint8_t>(lane), plugDetail, weapon)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+/** @return True when one policy row pays for the dismantled item. */
|
|
|
+[[nodiscard]] bool policy_matches(const DismantleRewardPolicy& policy,
|
|
|
+ std::uint8_t tier,
|
|
|
+ std::uint8_t gearClass,
|
|
|
+ bool isMasterworked) noexcept {
|
|
|
+ if (policy.tierMask != 0 && (policy.tierMask & (1U << tier)) == 0) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (policy.classMask != 0 && (policy.classMask & gearClass) == 0) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ switch (policy.masterwork) {
|
|
|
+ case DismantleMasterworkFilter::masterworked:
|
|
|
+ return isMasterworked;
|
|
|
+ case DismantleMasterworkFilter::notMasterworked:
|
|
|
+ return !isMasterworked;
|
|
|
+ default:
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Credits the supported client's ordinary weapon/armor dismantle payout.
|
|
|
*
|
|
|
- * Capped stacks
|
|
|
- * lose only the overflowing part, matching normal profile-inventory behavior.
|
|
|
- * Every credited row
|
|
|
- * receives a new mutation serial so the account observer can display it.
|
|
|
+ * Every policy row whose rarity, gear-class and masterwork filters match the dismantled item is
|
|
|
+ * summed per material first, so one material lands as one credited row. Capped stacks lose only
|
|
|
+ * the overflowing part, matching normal profile-inventory behavior; a stack already at its
|
|
|
+ * native cap drops that row's payout and says so in the log. Every credited row receives a new
|
|
|
+ * mutation serial so the account observer can display it.
|
|
|
*/
|
|
|
[[nodiscard]] bool
|
|
|
apply_dismantle_rewards(const AccountState& before,
|
|
|
+ const authored_inventory::Item& dismantledItem,
|
|
|
+ const build_data::items::Definition& dismantledDefinition,
|
|
|
+ const item_details::Definition& dismantledDetail,
|
|
|
std::uint8_t equipmentSlot,
|
|
|
AccountState& after,
|
|
|
std::array<DismantleReward, kDismantleRewardCapacity>& rewards,
|
|
|
@@ -90,9 +308,44 @@ apply_dismantle_rewards(const AccountState& before,
|
|
|
if (!valid_profile_inventory(before)) {
|
|
|
return false;
|
|
|
}
|
|
|
- if (equipmentSlot >= kGearEquipmentSlotCount) {
|
|
|
+ const std::uint8_t gearClass = gear_class_of(equipmentSlot);
|
|
|
+ if (gearClass == 0) {
|
|
|
return true;
|
|
|
}
|
|
|
+ const std::uint8_t tier = dismantledDefinition.tier;
|
|
|
+ const bool isMasterworked =
|
|
|
+ masterworked(dismantledItem,
|
|
|
+ dismantledDetail,
|
|
|
+ gearClass == static_cast<std::uint8_t>(DismantleGearClass::weapon));
|
|
|
+
|
|
|
+ // Sum the matching rows per material before crediting anything.
|
|
|
+ std::array<DismantleRewardPolicy, kDismantleRewardPolicyCapacity> payout{};
|
|
|
+ std::size_t payoutCount = 0;
|
|
|
+ for (std::size_t policyIndex = 0; policyIndex < before.dismantleRewardCount; ++policyIndex) {
|
|
|
+ const DismantleRewardPolicy& policy = before.dismantleRewards[policyIndex];
|
|
|
+ if (!policy_matches(policy, tier, gearClass, isMasterworked)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ std::size_t slot = payoutCount;
|
|
|
+ for (std::size_t index = 0; index < payoutCount; ++index) {
|
|
|
+ if (payout[index].definitionHash == policy.definitionHash) {
|
|
|
+ slot = index;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (slot == payoutCount) {
|
|
|
+ if (payoutCount >= payout.size()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ payout[payoutCount++] = {policy.definitionHash, 0};
|
|
|
+ }
|
|
|
+ if (policy.quantity > (std::numeric_limits<std::int32_t>::max)() - payout[slot].quantity) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ payout[slot].quantity += policy.quantity;
|
|
|
+ }
|
|
|
+ report_dismantle_reward_match(
|
|
|
+ dismantledDefinition.definitionHash, tier, gearClass, isMasterworked, payoutCount);
|
|
|
|
|
|
std::int32_t greatestMutationSerial = 0;
|
|
|
for (std::size_t index = 0; index < before.profileItemCount; ++index) {
|
|
|
@@ -100,8 +353,8 @@ apply_dismantle_rewards(const AccountState& before,
|
|
|
(std::max)(greatestMutationSerial, before.profileItems[index].mutationSerial);
|
|
|
}
|
|
|
|
|
|
- for (std::size_t policyIndex = 0; policyIndex < before.dismantleRewardCount; ++policyIndex) {
|
|
|
- const DismantleRewardPolicy& policy = before.dismantleRewards[policyIndex];
|
|
|
+ for (std::size_t policyIndex = 0; policyIndex < payoutCount; ++policyIndex) {
|
|
|
+ const DismantleRewardPolicy& policy = payout[policyIndex];
|
|
|
build_data::items::Definition definition{};
|
|
|
item_details::Definition detail{};
|
|
|
inventory_buckets::Descriptor bucket{};
|
|
|
@@ -139,6 +392,11 @@ apply_dismantle_rewards(const AccountState& before,
|
|
|
const bool appended = profileIndex == after.profileItemCount;
|
|
|
if ((appended && after.profileItemCount >= after.profileItems.size())
|
|
|
|| greatestMutationSerial == (std::numeric_limits<std::int32_t>::max)()) {
|
|
|
+ report_dismantle_reward_dropped(appended ? "profile_full" : "serial_exhausted",
|
|
|
+ policy.definitionHash,
|
|
|
+ policy.quantity,
|
|
|
+ 0,
|
|
|
+ detail.maxStackSize);
|
|
|
continue;
|
|
|
}
|
|
|
const std::int32_t previousQuantity =
|
|
|
@@ -146,6 +404,12 @@ apply_dismantle_rewards(const AccountState& before,
|
|
|
const std::int32_t available = detail.maxStackSize - previousQuantity;
|
|
|
const std::int32_t credited = (std::min)(policy.quantity, available);
|
|
|
if (credited <= 0) {
|
|
|
+ // Every stack of this currency is already at its native cap.
|
|
|
+ report_dismantle_reward_dropped("stack_capped",
|
|
|
+ policy.definitionHash,
|
|
|
+ policy.quantity,
|
|
|
+ previousQuantity,
|
|
|
+ detail.maxStackSize);
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
@@ -162,6 +426,11 @@ apply_dismantle_rewards(const AccountState& before,
|
|
|
}
|
|
|
// A full native bucket drops this reward, but never blocks deletion of the source item.
|
|
|
if (!account::valid(candidate) || !valid_profile_inventory(candidate)) {
|
|
|
+ report_dismantle_reward_dropped("bucket_full",
|
|
|
+ policy.definitionHash,
|
|
|
+ policy.quantity,
|
|
|
+ previousQuantity,
|
|
|
+ detail.maxStackSize);
|
|
|
continue;
|
|
|
}
|
|
|
if (rewardCount >= rewards.size()) {
|
|
|
@@ -313,7 +582,14 @@ apply_dismantle_rewards(const AccountState& before,
|
|
|
AccountState rewarded{};
|
|
|
std::array<DismantleReward, kDismantleRewardCapacity> rewards{};
|
|
|
std::size_t rewardCount = 0;
|
|
|
- if (!apply_dismantle_rewards(candidate, dismantledSlot, rewarded, rewards, rewardCount)) {
|
|
|
+ if (!apply_dismantle_rewards(candidate,
|
|
|
+ dismantledItem,
|
|
|
+ dismantledDefinition,
|
|
|
+ dismantledDetail,
|
|
|
+ dismantledSlot,
|
|
|
+ rewarded,
|
|
|
+ rewards,
|
|
|
+ rewardCount)) {
|
|
|
return false;
|
|
|
}
|
|
|
candidate = rewarded;
|