state_account_reward_grant_runtime.cpp 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946
  1. /**
  2. * Grants that no purchase pays for: season pass rewards, record rewards, and the
  3. * default emote collection.
  4. */
  5. #include <array>
  6. #include <cstddef>
  7. #include <cstdint>
  8. #include <limits>
  9. #include "../../middleware/datagen/family4/loadout/loadout_resolver.h"
  10. #include "../build_data/runtime.h"
  11. #include "../investment/store_internal.h"
  12. #include "../rewards/reward_resolver.h"
  13. #include "../unlocks/unlocks_records.h"
  14. #include "runtime.h"
  15. #include "state_account_transaction_helpers.h"
  16. #include "storage/internal.h"
  17. namespace sunrise::state {
  18. using namespace runtime::detail;
  19. namespace authored_inventory = account::inventory;
  20. namespace item_details = build_data::items::details;
  21. namespace inventory_buckets = build_data::inventory::buckets;
  22. namespace family4_loadout = middleware::datagen::family4::loadout;
  23. namespace {
  24. [[nodiscard]] bool materialize_record_reward(const AccountState& current,
  25. const PendingRecordRewardGrant& mutation,
  26. AccountState& after) noexcept;
  27. /** A reward overrides socket types, so adding an ordinary lane cannot shift a fixed roll. */
  28. [[nodiscard]] bool
  29. apply_reward_sockets(const item_details::Definition& detail,
  30. std::span<const build_data::rewards::SocketOverride> overrides,
  31. authored_inventory::Sockets& sockets,
  32. const char** refusal = nullptr) noexcept {
  33. const auto fail = [&](const char* reason) noexcept {
  34. if (refusal != nullptr) {
  35. *refusal = reason;
  36. }
  37. return false;
  38. };
  39. if (overrides.empty()) {
  40. return true;
  41. }
  42. if (detail.ordinarySocketCount > sockets.plugs.size()) {
  43. return fail("socket_layout");
  44. }
  45. authored_inventory::Sockets staged{};
  46. staged.policy = authored_inventory::SocketPolicy::authored;
  47. staged.plugCount = detail.ordinarySocketCount;
  48. for (std::size_t lane = 0; lane < staged.plugCount; ++lane) {
  49. const auto index = detail.initialPlugIndices[lane];
  50. if (index == item_details::kUnavailableItemIndex) {
  51. continue;
  52. }
  53. build_data::items::Definition plug{};
  54. if (!build_data::find_item_definition_index(index, plug)) {
  55. return fail("socket_layout");
  56. }
  57. staged.plugs[lane] = plug.definitionHash;
  58. }
  59. std::array<bool, item_details::kInitialPlugCapacity> replaced{};
  60. for (const auto& override : overrides) {
  61. if (override.plugSet != build_data::rewards::kAbsent) {
  62. return fail("socket_plug_set");
  63. }
  64. if (override.rollSet != build_data::rewards::kAbsent) {
  65. return fail("socket_roll_set");
  66. }
  67. // Zero selection preserves the initial plug in every lane of the named type.
  68. const bool preserve =
  69. override.plugItem == build_data::rewards::kAbsent && override.selection == 0;
  70. if (!preserve && override.selection != build_data::rewards::kFixedPlugSelection) {
  71. return fail("socket_selection");
  72. }
  73. build_data::items::Definition plug{};
  74. if (!preserve && !build_data::find_item_definition_index(override.plugItem, plug)) {
  75. return fail("socket_layout");
  76. }
  77. bool found = false;
  78. for (std::size_t lane = 0; lane < staged.plugCount; ++lane) {
  79. if (detail.socketTypes[lane] != override.socketType) {
  80. continue;
  81. }
  82. if (replaced[lane] || (!preserve && found)) {
  83. return fail("socket_layout");
  84. }
  85. replaced[lane] = true;
  86. found = true;
  87. if (!preserve) {
  88. staged.plugs[lane] = plug.definitionHash;
  89. }
  90. }
  91. if (!found) {
  92. return fail("socket_layout");
  93. }
  94. }
  95. if (!authored_inventory::valid(staged)) {
  96. return fail("socket_layout");
  97. }
  98. sockets = staged;
  99. return true;
  100. }
  101. [[nodiscard]] RewardPreparation prepare_resolved_reward(const rewards::Result& resolved,
  102. PendingRecordRewardGrant& mutation,
  103. const char** refusal) noexcept {
  104. std::array<DirectRecordReward, kRecordRewardGrantCapacity> rows{};
  105. if (resolved.count > rows.size()) {
  106. return RewardPreparation::unresolvable;
  107. }
  108. for (std::size_t i = 0; i < resolved.count; ++i) {
  109. const auto& grant = resolved.grants[i];
  110. rows[i] = {grant.itemIndex,
  111. grant.quantity,
  112. std::span(grant.sockets).first(grant.socketCount),
  113. true};
  114. }
  115. return prepare_record_reward_grant(
  116. std::span(rows).first(resolved.count), kUnclaimedRecordIndex, mutation, refusal);
  117. }
  118. /** Classifies a catalog refusal: a verdict unless a catalog was cleared meanwhile. */
  119. [[nodiscard]] RewardPreparation catalog_refusal(const char*& reason) noexcept {
  120. if (build_data::item_definitions_ready() && build_data::reward_definitions_ready()) {
  121. return RewardPreparation::unresolvable;
  122. }
  123. reason = "not_ready";
  124. return RewardPreparation::deferred;
  125. }
  126. /** Wrappers and direct account perks bypass quest initialization. */
  127. bool uses_reward_definition(const build_data::items::Definition& item) noexcept {
  128. build_data::rewards::Item reward{};
  129. if (!build_data::find_reward_item(item.definitionIndex, reward)) {
  130. return false;
  131. }
  132. return reward.poolIndex != build_data::rewards::kAbsent
  133. || (item.bucketId == inventory_buckets::kPerkBucketId
  134. && reward.acquiredFlag != build_data::rewards::kAbsent);
  135. }
  136. enum class PassResolution { claim, replay };
  137. [[nodiscard]] bool resolve_pass(const build_data::season_pass::Reward& reward,
  138. const AccountState& account,
  139. std::uint64_t seed,
  140. rewards::Result& result,
  141. PassResolution resolution,
  142. const char** reason = nullptr) noexcept {
  143. const auto character = selected_character_index(account);
  144. unlocks::Table flags{};
  145. build_data::rewards::Item source{};
  146. if (character >= account.characterCount
  147. || !investment::store::read_unlocks(flags, static_cast<int>(character))
  148. || !build_data::find_reward_item(reward.itemIndex, source)
  149. || source.definitionHash != reward.itemHash || reward.socketCount > reward.sockets.size()
  150. || reward.conditionCount > reward.condition.size()) {
  151. return false;
  152. }
  153. if (reason != nullptr) {
  154. *reason = "reward_condition";
  155. }
  156. if (resolution == PassResolution::replay && reward.claimFlagIndex < flags.accountFlags.size()) {
  157. flags.accountFlags[reward.claimFlagIndex] = unlocks::kFlagClear;
  158. }
  159. bool enabled = false;
  160. if (!rewards::eligible(std::span(reward.condition).first(reward.conditionCount),
  161. {flags, account.characters[character].characterClass, seed, reason},
  162. enabled)
  163. || !enabled) {
  164. return false;
  165. }
  166. if (rewards::resolve({flags, account.characters[character].characterClass, seed, reason},
  167. reward.itemIndex,
  168. reward.quantity,
  169. result)
  170. != rewards::Resolution::resolved) {
  171. return false;
  172. }
  173. if (reward.socketCount != 0) {
  174. if (result.count != 1 || result.grants[0].itemIndex != reward.itemIndex) {
  175. if (reason != nullptr) {
  176. *reason = "socket_owner";
  177. }
  178. return false;
  179. }
  180. result.grants[0].sockets = reward.sockets;
  181. result.grants[0].socketCount = reward.socketCount;
  182. }
  183. return true;
  184. }
  185. [[nodiscard]] bool reward_matches(const build_data::season_pass::Reward& reward,
  186. const PendingSeasonPassReward& mutation) noexcept {
  187. const auto* grant = &mutation.grant;
  188. rewards::Result expected{};
  189. if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash
  190. || !resolve_pass(
  191. reward, account_snapshot(), mutation.seed, expected, PassResolution::replay)
  192. || expected.count != grant->rewardCount) {
  193. return false;
  194. }
  195. for (std::size_t i = 0; i < expected.count; ++i) {
  196. const auto& planned = expected.grants[i];
  197. const auto& prepared = grant->rewards[i];
  198. build_data::items::Definition definition{};
  199. build_data::rewards::Item source{};
  200. if (!build_data::find_item_definition_index(planned.itemIndex, definition)
  201. || !build_data::find_reward_item(planned.itemIndex, source)
  202. || prepared.definitionHash != definition.definitionHash
  203. || prepared.quantity != planned.quantity
  204. || prepared.acquiredFlag != source.acquiredFlag) {
  205. return false;
  206. }
  207. if (planned.socketCount != 0) {
  208. item_details::Definition detail{};
  209. authored_inventory::Sockets sockets{};
  210. if (prepared.kind != RecordRewardKind::characterInstance
  211. || prepared.stateIndex >= grant->afterCharacter.inventory.count
  212. || !build_data::find_configured_item_detail(planned.itemIndex, detail)
  213. || !apply_reward_sockets(
  214. detail, std::span(planned.sockets).first(planned.socketCount), sockets)) {
  215. return false;
  216. }
  217. const auto& installed =
  218. grant->afterCharacter.inventory.values[prepared.stateIndex].sockets;
  219. if (installed.policy != sockets.policy || installed.plugCount != sockets.plugCount
  220. || installed.plugs != sockets.plugs) {
  221. return false;
  222. }
  223. }
  224. }
  225. return true;
  226. }
  227. } // namespace
  228. bool prepare_season_pass_reward(std::uint16_t rewardIndex,
  229. std::uint64_t seed,
  230. PendingSeasonPassReward& mutation,
  231. const char** refusal) noexcept {
  232. const char* unused = nullptr;
  233. auto& reason = refusal != nullptr ? *refusal : unused;
  234. reason = "reward_index";
  235. mutation = {};
  236. const std::lock_guard lock(investment::store::g_mutex);
  237. build_data::season_pass::Reward reward{};
  238. if (!build_data::find_season_pass_reward(rewardIndex, reward)) {
  239. return false;
  240. }
  241. reason = "rank";
  242. if (reward.requiredRank > seasonal_rank()) {
  243. return false;
  244. }
  245. reason = "already_claimed";
  246. if (season_pass_reward_claimed(rewardIndex)) {
  247. return false;
  248. }
  249. mutation.seed = seed;
  250. rewards::Result resolved{};
  251. reason = "reward_condition";
  252. if (!resolve_pass(
  253. reward, account_snapshot(), mutation.seed, resolved, PassResolution::claim, &reason)) {
  254. return false;
  255. }
  256. reason = "reward_placement";
  257. if (prepare_resolved_reward(resolved, mutation.grant, &reason) != RewardPreparation::prepared) {
  258. return false;
  259. }
  260. reason = nullptr;
  261. mutation.sourceDefinitionHash = reward.itemHash;
  262. mutation.rewardIndex = rewardIndex;
  263. mutation.prepared = true;
  264. return true;
  265. }
  266. ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept {
  267. build_data::items::Definition item{};
  268. if (!build_data::find_item_definition_index(itemIndex, item)) {
  269. return ItemGrantRoute::unavailable;
  270. }
  271. if (uses_reward_definition(item)) {
  272. return ItemGrantRoute::reward;
  273. }
  274. if (item.questInitialization.scope != build_data::items::QuestInitialization::Scope::none) {
  275. return ItemGrantRoute::quest;
  276. }
  277. item_details::Definition detail{};
  278. inventory_buckets::Descriptor bucket{};
  279. if (!build_data::find_configured_item_detail(itemIndex, detail)
  280. || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) {
  281. return ItemGrantRoute::unavailable;
  282. }
  283. if (bucket.arraySelector == inventory_buckets::ArraySelector::profile
  284. && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable) {
  285. return ItemGrantRoute::profile;
  286. }
  287. return bucket.arraySelector == inventory_buckets::ArraySelector::character
  288. ? ItemGrantRoute::reward
  289. : ItemGrantRoute::unavailable;
  290. }
  291. RewardPreparation prepare_item_reward(std::uint16_t itemIndex,
  292. std::uint32_t quantity,
  293. std::uint64_t seed,
  294. PendingRecordRewardGrant& mutation,
  295. const char** refusal) noexcept {
  296. const char* unused = nullptr;
  297. auto& reason = refusal != nullptr ? *refusal : unused;
  298. reason = "not_ready";
  299. mutation = {};
  300. // Unpublished domains decide nothing; retry later.
  301. if (!build_data::item_definitions_ready() || !build_data::reward_definitions_ready()) {
  302. return RewardPreparation::deferred;
  303. }
  304. reason = "item_definition";
  305. const std::lock_guard lock(investment::store::g_mutex);
  306. build_data::items::Definition item{};
  307. if (!build_data::find_item_definition_index(itemIndex, item)) {
  308. return catalog_refusal(reason);
  309. }
  310. reason = "selected_character";
  311. const AccountState account = account_snapshot();
  312. const auto character = selected_character_index(account);
  313. unlocks::Table flags{};
  314. if (character >= account.characterCount
  315. || !investment::store::read_unlocks(flags, static_cast<int>(character))) {
  316. return RewardPreparation::deferred;
  317. }
  318. rewards::Result resolved{};
  319. const auto resolution =
  320. rewards::resolve({flags, account.characters[character].characterClass, seed, &reason},
  321. itemIndex,
  322. quantity,
  323. resolved);
  324. // The saved unlocks can change, so an empty draw they caused is retried, not retired.
  325. if (resolution == rewards::Resolution::ineligible) {
  326. return RewardPreparation::deferred;
  327. }
  328. if (resolution != rewards::Resolution::resolved) {
  329. return catalog_refusal(reason);
  330. }
  331. reason = "reward_placement";
  332. return prepare_resolved_reward(resolved, mutation, &reason);
  333. }
  334. bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation,
  335. unlocks::Table& after) noexcept {
  336. if (!mutation.prepared || mutation.rewardCount > mutation.rewards.size()
  337. || !investment::store::read_unlocks(after, static_cast<int>(mutation.characterIndex))) {
  338. return false;
  339. }
  340. for (std::size_t i = 0; i < mutation.rewardCount; ++i) {
  341. const auto& reward = mutation.rewards[i];
  342. if (reward.acquiredFlag == build_data::rewards::kAbsent) {
  343. continue;
  344. }
  345. if (reward.acquiredFlag >= after.accountFlags.size()
  346. || after.accountFlags[reward.acquiredFlag] != reward.previousFlag) {
  347. return false;
  348. }
  349. }
  350. for (std::size_t i = 0; i < mutation.rewardCount; ++i) {
  351. const auto flag = mutation.rewards[i].acquiredFlag;
  352. if (flag != build_data::rewards::kAbsent) {
  353. after.accountFlags[flag] = unlocks::kFlagSet;
  354. }
  355. }
  356. return true;
  357. }
  358. /** Revalidates the prepared reward under the lock that commits its inventory and flags. */
  359. bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept {
  360. if (!mutation.prepared) {
  361. return false;
  362. }
  363. const PendingConsumption consume{mutation};
  364. bool ready = false;
  365. {
  366. investment::store::Transaction transaction;
  367. build_data::season_pass::Reward reward{};
  368. ready = transaction.ready()
  369. && build_data::find_season_pass_reward(mutation.rewardIndex, reward)
  370. && season_pass_reward_claimed(mutation.rewardIndex)
  371. && reward.requiredRank <= seasonal_rank() && reward_matches(reward, mutation)
  372. && commit_record_reward(mutation.grant) && transaction.commit();
  373. }
  374. if (!ready) {
  375. revoke_season_pass_reward(mutation.rewardIndex);
  376. }
  377. return ready;
  378. }
  379. namespace {
  380. /**
  381. * Rebuilds the account after-image for one prepared reward set and rejects any drift.
  382. * @param current Live account the mutation was prepared against.
  383. * @param after Receives the after-image; its content is unusable on failure.
  384. * @return False when the account moved or a reward row breaks its own item rules.
  385. */
  386. [[nodiscard]] bool materialize_record_reward(const AccountState& current,
  387. const PendingRecordRewardGrant& mutation,
  388. AccountState& after) noexcept {
  389. if (!mutation.prepared || mutation.rewardCount == 0
  390. || mutation.rewardCount > mutation.rewards.size() || mutation.accountSoid == 0
  391. || mutation.characterSoid == 0 || mutation.characterIndex >= current.characterCount
  392. || current.primarySoid != mutation.accountSoid
  393. || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter)
  394. || !same_profile_inventory(
  395. current, mutation.beforeProfileItems, mutation.beforeProfileItemCount)
  396. || !mutation.beforeCharacter.selected
  397. || mutation.beforeCharacter.soid != mutation.characterSoid) {
  398. return false;
  399. }
  400. after = current;
  401. after.characters[mutation.characterIndex] = mutation.afterCharacter;
  402. after.profileItems = mutation.afterProfileItems;
  403. after.profileItemCount = mutation.afterProfileItemCount;
  404. family4_loadout::ResolvedLoadout loadout{};
  405. if (!account::valid(after) || !valid_profile_inventory(after)
  406. || !family4_loadout::resolve(after, mutation.characterIndex, loadout)) {
  407. return false;
  408. }
  409. for (std::size_t index = 0; index < mutation.rewardCount; ++index) {
  410. const PreparedRecordReward& reward = mutation.rewards[index];
  411. build_data::items::Definition item{};
  412. item_details::Definition detail{};
  413. inventory_buckets::Descriptor bucket{};
  414. if (reward.definitionHash == authored_inventory::kNoDefinitionHash || reward.quantity <= 0
  415. || reward.afterQuantity < reward.quantity || reward.mutationSerial < 0
  416. || !build_data::find_item_definition_hash(reward.definitionHash, item)) {
  417. return false;
  418. }
  419. if (reward.kind == RecordRewardKind::accountUnlock) {
  420. build_data::rewards::Item source{};
  421. if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid != 0
  422. || item.bucketId != build_data::inventory::buckets::kPerkBucketId
  423. || !build_data::find_reward_item(item.definitionIndex, source)
  424. || source.acquiredFlag == build_data::rewards::kAbsent
  425. || source.acquiredFlag != reward.acquiredFlag) {
  426. return false;
  427. }
  428. continue;
  429. }
  430. if (!build_data::find_configured_item_detail(item.definitionIndex, detail)
  431. || detail.definitionIndex != item.definitionIndex
  432. || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
  433. || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) {
  434. return false;
  435. }
  436. if (reward.kind == RecordRewardKind::characterInstance) {
  437. if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid == 0
  438. || reward.appendedProfileResident
  439. || detail.instancedDefinitionState
  440. != item_details::InstancedDefinitionState::instanced
  441. || bucket.arraySelector != inventory_buckets::ArraySelector::character
  442. || reward.stateIndex >= mutation.afterCharacter.inventory.count) {
  443. return false;
  444. }
  445. const auto& granted = mutation.afterCharacter.inventory.values[reward.stateIndex];
  446. std::uint16_t row = 0;
  447. std::uint8_t slot = 0;
  448. if (granted.instanceSoid != reward.instanceSoid
  449. || granted.definitionHash != reward.definitionHash || granted.quantity != 1
  450. || granted.mutationSerial != reward.mutationSerial
  451. || !find_unequipped_row(loadout, reward.instanceSoid, row, slot)
  452. || row != reward.inventoryRow) {
  453. return false;
  454. }
  455. } else if (reward.kind == RecordRewardKind::characterStack) {
  456. if (reward.instanceSoid != 0 || reward.appendedProfileResident
  457. || detail.equipmentSlot.has_value()
  458. || detail.instancedDefinitionState
  459. != item_details::InstancedDefinitionState::stackable
  460. || bucket.arraySelector != inventory_buckets::ArraySelector::character
  461. || reward.afterQuantity > detail.maxStackSize
  462. || reward.stateIndex >= mutation.afterCharacter.stacks.count) {
  463. return false;
  464. }
  465. const auto& granted = mutation.afterCharacter.stacks.values[reward.stateIndex];
  466. if (granted.definitionHash != reward.definitionHash
  467. || granted.quantity != reward.afterQuantity
  468. || granted.mutationSerial != reward.mutationSerial) {
  469. return false;
  470. }
  471. } else {
  472. if (reward.kind != RecordRewardKind::profileStack
  473. || detail.instancedDefinitionState
  474. != item_details::InstancedDefinitionState::stackable
  475. || bucket.arraySelector != inventory_buckets::ArraySelector::profile
  476. || reward.afterQuantity > detail.maxStackSize
  477. || reward.stateIndex >= mutation.afterProfileItemCount) {
  478. return false;
  479. }
  480. const auto& granted = mutation.afterProfileItems[reward.stateIndex];
  481. const bool actionSource =
  482. build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
  483. if (granted.instanceSoid != reward.instanceSoid
  484. || granted.definitionHash != reward.definitionHash
  485. || granted.quantity != reward.afterQuantity
  486. || granted.mutationSerial != reward.mutationSerial
  487. || actionSource != (reward.instanceSoid != 0)
  488. || reward.appendedProfileResident
  489. != (actionSource && reward.stateIndex >= mutation.beforeProfileItemCount)) {
  490. return false;
  491. }
  492. }
  493. }
  494. return true;
  495. }
  496. } // namespace
  497. /** Prepares every reward over one cumulative account view. */
  498. RewardPreparation prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
  499. std::uint16_t claimedRecordIndex,
  500. PendingRecordRewardGrant& mutation,
  501. const char** refusal) noexcept {
  502. const char* unused = nullptr;
  503. auto& reason = refusal != nullptr ? *refusal : unused;
  504. reason = "reward_count";
  505. mutation = {};
  506. if (rewards.empty() || rewards.size() > mutation.rewards.size()) {
  507. return RewardPreparation::unresolvable;
  508. }
  509. reason = "not_ready";
  510. // The loadout check needs these domains, so nothing is decided before they publish.
  511. if (!build_data::item_definitions_ready() || !build_data::configured_item_details_ready()
  512. || !build_data::inventory_bucket_descriptors_ready()
  513. || !build_data::socket_entry_lists_ready()) {
  514. return RewardPreparation::deferred;
  515. }
  516. reason = "account_state";
  517. const AccountState account = account_snapshot();
  518. const std::size_t characterIndex = selected_character_index(account);
  519. if (!account::valid(account) || !valid_profile_inventory(account)
  520. || characterIndex >= account.characterCount) {
  521. return RewardPreparation::deferred;
  522. }
  523. AccountState working = account;
  524. for (std::size_t index = 0; index < rewards.size(); ++index) {
  525. reason = "item_identity";
  526. const DirectRecordReward& requested = rewards[index];
  527. build_data::items::Definition item{};
  528. item_details::Definition detail{};
  529. inventory_buckets::Descriptor bucket{};
  530. if (requested.quantity <= 0
  531. || !build_data::find_item_definition_index(requested.itemDefinitionIndex, item)
  532. || item.questInitialization.scope
  533. != build_data::items::QuestInitialization::Scope::none) {
  534. return RewardPreparation::unresolvable;
  535. }
  536. PreparedRecordReward prepared{};
  537. prepared.definitionHash = item.definitionHash;
  538. prepared.quantity = requested.quantity;
  539. if (requested.acquireUnlock) {
  540. reason = "acquisition_flag";
  541. // Only a resolved reward asks for its flag, so the graph is published.
  542. build_data::rewards::Item source{};
  543. if (!build_data::find_reward_item(item.definitionIndex, source)
  544. || source.definitionHash != item.definitionHash) {
  545. return RewardPreparation::unresolvable;
  546. }
  547. prepared.acquiredFlag = source.acquiredFlag;
  548. if (source.acquiredFlag != build_data::rewards::kAbsent) {
  549. std::int32_t before = 0;
  550. // The saved flag refuses here, not the definition.
  551. if (!investment::store::read_unlock(
  552. investment::store::Bank::accountFlags, source.acquiredFlag, before)
  553. || before < 0 || before > unlocks::kFlagSet) {
  554. return RewardPreparation::deferred;
  555. }
  556. prepared.previousFlag = static_cast<std::uint8_t>(before);
  557. }
  558. }
  559. if (item.bucketId == build_data::inventory::buckets::kPerkBucketId) {
  560. reason = "perk_acquisition";
  561. if (prepared.acquiredFlag == build_data::rewards::kAbsent || requested.quantity != 1
  562. || !requested.sockets.empty()) {
  563. return RewardPreparation::unresolvable;
  564. }
  565. prepared.kind = RecordRewardKind::accountUnlock;
  566. prepared.afterQuantity = 1;
  567. mutation.rewards[index] = prepared;
  568. continue;
  569. }
  570. reason = "item_detail";
  571. if (!build_data::find_configured_item_detail(requested.itemDefinitionIndex, detail)
  572. || detail.definitionIndex != item.definitionIndex
  573. || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
  574. || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) {
  575. return RewardPreparation::unresolvable;
  576. }
  577. reason = "duplicate_stack";
  578. if (detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable) {
  579. for (std::size_t prior = 0; prior < index; ++prior) {
  580. if (mutation.rewards[prior].definitionHash == item.definitionHash) {
  581. return RewardPreparation::unresolvable;
  582. }
  583. }
  584. }
  585. reason = "socket_owner";
  586. if (!requested.sockets.empty()
  587. && (bucket.arraySelector != inventory_buckets::ArraySelector::character
  588. || detail.instancedDefinitionState
  589. != item_details::InstancedDefinitionState::instanced)) {
  590. return RewardPreparation::unresolvable;
  591. }
  592. reason = "inventory_destination";
  593. if (bucket.arraySelector == inventory_buckets::ArraySelector::profile) {
  594. reason = "profile_capacity";
  595. if (detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable
  596. || requested.quantity > detail.maxStackSize) {
  597. return RewardPreparation::unresolvable;
  598. }
  599. PendingProfileItemAcquisition staged{};
  600. const bool actionSource =
  601. build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
  602. if (!finalize_profile_item_acquisition(working,
  603. working,
  604. item.definitionHash,
  605. detail,
  606. actionSource,
  607. requested.quantity,
  608. {.direct = true},
  609. staged)) {
  610. return RewardPreparation::deferred;
  611. }
  612. working.profileItems = staged.afterItems;
  613. working.profileItemCount = staged.afterItemCount;
  614. prepared.instanceSoid = staged.acquiredInstanceSoid;
  615. prepared.stateIndex = staged.profileIndex;
  616. prepared.afterQuantity = staged.acquiredQuantity;
  617. prepared.mutationSerial = staged.acquiredMutationSerial;
  618. prepared.kind = RecordRewardKind::profileStack;
  619. prepared.appendedProfileResident = staged.appended && staged.actionSource;
  620. } else if (bucket.arraySelector == inventory_buckets::ArraySelector::character
  621. && detail.instancedDefinitionState
  622. == item_details::InstancedDefinitionState::instanced) {
  623. reason = "instance_quantity";
  624. if (requested.quantity != 1) {
  625. return RewardPreparation::unresolvable;
  626. }
  627. reason = "instance_capacity";
  628. PendingItemAcquisition staged{};
  629. if (!finalize_item_acquisition(
  630. working, working, item.definitionHash, false, {.direct = true}, staged)) {
  631. return RewardPreparation::deferred;
  632. }
  633. if (!apply_reward_sockets(
  634. detail,
  635. requested.sockets,
  636. staged.afterCharacter.inventory.values[staged.inventoryIndex].sockets,
  637. &reason)) {
  638. return RewardPreparation::unresolvable;
  639. }
  640. working.characters[characterIndex] = staged.afterCharacter;
  641. prepared.instanceSoid = staged.acquiredInstanceSoid;
  642. prepared.stateIndex = staged.inventoryIndex;
  643. prepared.afterQuantity = 1;
  644. prepared.mutationSerial =
  645. staged.afterCharacter.inventory.values[staged.inventoryIndex].mutationSerial;
  646. prepared.inventoryRow = staged.inventoryRow;
  647. prepared.kind = RecordRewardKind::characterInstance;
  648. } else if (bucket.arraySelector == inventory_buckets::ArraySelector::character
  649. && detail.instancedDefinitionState
  650. == item_details::InstancedDefinitionState::stackable
  651. && !detail.equipmentSlot.has_value()) {
  652. reason = "character_stack_capacity";
  653. CharacterState& character = working.characters[characterIndex];
  654. if (requested.quantity > detail.maxStackSize) {
  655. return RewardPreparation::unresolvable;
  656. }
  657. if (character.nextInventorySerial
  658. >= static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())) {
  659. return RewardPreparation::deferred;
  660. }
  661. std::size_t stackIndex = character.stacks.count;
  662. for (std::size_t candidate = 0; candidate < character.stacks.count; ++candidate) {
  663. if (character.stacks.values[candidate].definitionHash == item.definitionHash) {
  664. stackIndex = candidate;
  665. break;
  666. }
  667. }
  668. const bool appended = stackIndex == character.stacks.count;
  669. if ((appended && stackIndex >= character.stacks.values.size())
  670. || (!appended
  671. && character.stacks.values[stackIndex].quantity
  672. > detail.maxStackSize - requested.quantity)) {
  673. return RewardPreparation::deferred;
  674. }
  675. auto& stack = character.stacks.values[stackIndex];
  676. if (appended) {
  677. stack.definitionHash = item.definitionHash;
  678. ++character.stacks.count;
  679. }
  680. stack.quantity += requested.quantity;
  681. stack.mutationSerial = static_cast<std::int32_t>(character.nextInventorySerial++);
  682. prepared.stateIndex = stackIndex;
  683. prepared.afterQuantity = stack.quantity;
  684. prepared.mutationSerial = stack.mutationSerial;
  685. prepared.kind = RecordRewardKind::characterStack;
  686. } else {
  687. return RewardPreparation::unresolvable;
  688. }
  689. mutation.rewards[index] = prepared;
  690. }
  691. reason = "loadout";
  692. // A full native bucket rejects the whole loadout; retry later.
  693. family4_loadout::ResolvedLoadout loadout{};
  694. if (!account::valid(working) || !valid_profile_inventory(working)
  695. || !family4_loadout::resolve(working, characterIndex, loadout)) {
  696. return RewardPreparation::deferred;
  697. }
  698. mutation.beforeCharacter = account.characters[characterIndex];
  699. mutation.afterCharacter = working.characters[characterIndex];
  700. mutation.beforeProfileItems = account.profileItems;
  701. mutation.afterProfileItems = working.profileItems;
  702. mutation.claimedRecordIndex = claimedRecordIndex;
  703. mutation.accountSoid = account.primarySoid;
  704. mutation.characterSoid = account.characters[characterIndex].soid;
  705. mutation.characterIndex = characterIndex;
  706. mutation.beforeProfileItemCount = account.profileItemCount;
  707. mutation.afterProfileItemCount = working.profileItemCount;
  708. mutation.rewardCount = rewards.size();
  709. reason = nullptr;
  710. mutation.prepared = true;
  711. return RewardPreparation::prepared;
  712. }
  713. bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation,
  714. AccountState& after) noexcept {
  715. after = {};
  716. return materialize_record_reward(account_snapshot(), mutation, after);
  717. }
  718. /** Commits the shared reward after-image and claim together. */
  719. bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept {
  720. if (!mutation.prepared) {
  721. return false;
  722. }
  723. const PendingConsumption consume{mutation};
  724. bool ready = false;
  725. {
  726. investment::store::Transaction transaction;
  727. AccountState after{};
  728. unlocks::Table flags{};
  729. ready = transaction.ready()
  730. && materialize_record_reward(investment::store::account(), mutation, after)
  731. && preview_reward_unlocks(mutation, flags)
  732. && investment::store::write_account(after);
  733. for (std::size_t i = 0; ready && i < mutation.rewardCount; ++i) {
  734. const auto flag = mutation.rewards[i].acquiredFlag;
  735. if (flag != build_data::rewards::kAbsent) {
  736. ready = investment::store::write_unlock(
  737. investment::store::Bank::accountFlags, flag, unlocks::kFlagSet);
  738. }
  739. }
  740. ready = ready && transaction.commit();
  741. }
  742. if (!ready && mutation.claimedRecordIndex != kUnclaimedRecordIndex) {
  743. // The claim was written when the reward was prepared, so a refused install undoes it.
  744. unlocks::records::revoke(mutation.claimedRecordIndex);
  745. }
  746. return ready;
  747. }
  748. namespace {
  749. /**
  750. * Emote definitions seeded into the collection item's wheel lanes.
  751. * All four are universal Common emotes, so no class or race can reject a seeded lane.
  752. */
  753. constexpr std::uint32_t kYesEmoteDefinitionHash = 3184938442U;
  754. constexpr std::uint32_t kNopeEmoteDefinitionHash = 48790291U;
  755. constexpr std::uint32_t kCasualSitEmoteDefinitionHash = 383973261U;
  756. constexpr std::uint32_t kCheerEmoteDefinitionHash = 2834933816U;
  757. /** Lane order is the client's own wheel layout. */
  758. constexpr std::array<std::uint32_t, authored_inventory::kEmoteCollectionSocketLaneCount>
  759. kEmoteCollectionDefaultPlugHashes{
  760. kCheerEmoteDefinitionHash, // lane 0 -- top
  761. kCasualSitEmoteDefinitionHash, // lane 1 -- bottom
  762. kYesEmoteDefinitionHash, // lane 2 -- left
  763. kNopeEmoteDefinitionHash, // lane 3 -- right
  764. };
  765. /**
  766. * Resolves and cross-checks the "Emotes" collection item's own configured content.
  767. * @param definition Receives the matching native item-definition row.
  768. * @return True only when both rows agree, declare no native equipment slot, and carry exactly the
  769. * expected 4 ordinary socket lanes.
  770. */
  771. [[nodiscard]] bool
  772. resolve_emote_collection_definition(build_data::items::Definition& definition) noexcept {
  773. item_details::Definition detail{};
  774. return build_data::find_item_definition_hash(authored_inventory::kEmoteCollectionDefinitionHash,
  775. definition)
  776. && definition.definitionHash == authored_inventory::kEmoteCollectionDefinitionHash
  777. && build_data::find_configured_item_detail(definition.definitionIndex, detail)
  778. && detail.definitionIndex == definition.definitionIndex
  779. && detail.definitionHash == authored_inventory::kEmoteCollectionDefinitionHash
  780. && detail.bucketId == definition.bucketId && !detail.equipmentSlot.has_value()
  781. && detail.ordinarySocketState == item_details::OrdinarySocketState::present
  782. && detail.ordinarySocketCount == authored_inventory::kEmoteCollectionSocketLaneCount;
  783. }
  784. /** Checks every seeded plug is installed and allowed in its lane before an item can carry it. */
  785. [[nodiscard]] bool default_plugs_valid(std::uint16_t collectionDefinitionIndex) noexcept {
  786. for (std::size_t lane = 0; lane < kEmoteCollectionDefaultPlugHashes.size(); ++lane) {
  787. build_data::items::Definition plugDefinition{};
  788. if (!build_data::find_item_definition_hash(kEmoteCollectionDefaultPlugHashes[lane],
  789. plugDefinition)
  790. || !build_data::is_socket_plug_allowed(collectionDefinitionIndex,
  791. static_cast<std::uint8_t>(lane),
  792. plugDefinition.definitionIndex)) {
  793. return false;
  794. }
  795. }
  796. return true;
  797. }
  798. /** Checks an equipped collection item's plugs so a stale set is repaired, not trusted. */
  799. [[nodiscard]] bool socket_state_sound(const authored_inventory::Item& item,
  800. std::uint16_t collectionDefinitionIndex) noexcept {
  801. if (item.sockets.policy != authored_inventory::SocketPolicy::authored
  802. || item.sockets.plugCount != authored_inventory::kEmoteCollectionSocketLaneCount) {
  803. return false;
  804. }
  805. for (std::size_t lane = 0; lane < authored_inventory::kEmoteCollectionSocketLaneCount; ++lane) {
  806. const std::optional<std::uint32_t>& plugHash = item.sockets.plugs[lane];
  807. build_data::items::Definition plugDefinition{};
  808. if (!plugHash.has_value()
  809. || !build_data::find_item_definition_hash(*plugHash, plugDefinition)
  810. || !build_data::is_socket_plug_allowed(collectionDefinitionIndex,
  811. static_cast<std::uint8_t>(lane),
  812. plugDefinition.definitionIndex)) {
  813. return false;
  814. }
  815. }
  816. return true;
  817. }
  818. } // namespace
  819. /**
  820. * Equips each character with the "Emotes" collection item in the emote slot.
  821. * Its content declares no native slot, so every resolver reaches it through
  822. * resolve_native_equipment_slot. Its 4 lanes have no native default and are seeded here.
  823. */
  824. EmoteCollectionOutcome ensure_character_emote_collection() noexcept {
  825. // The wheel occupies the authored emote equipment slot.
  826. constexpr std::size_t kEmoteCollectionSlot =
  827. static_cast<std::size_t>(authored_inventory::EquipmentSlot::emote);
  828. // Nothing can be concluded before these domains publish, so this is a retry, not a verdict.
  829. if (!build_data::item_definitions_ready() || !build_data::configured_item_details_ready()
  830. || !build_data::socket_plug_rules_ready()) {
  831. return EmoteCollectionOutcome::notReady;
  832. }
  833. // Published and still unresolved means the build cannot carry the wheel; a retry never helps.
  834. build_data::items::Definition collectionDefinition{};
  835. if (!resolve_emote_collection_definition(collectionDefinition)
  836. || !default_plugs_valid(collectionDefinition.definitionIndex)) {
  837. return EmoteCollectionOutcome::unsupported;
  838. }
  839. investment::store::g_mutex.lock();
  840. AccountState candidate = investment::store::account();
  841. if (!account::valid(candidate)) {
  842. investment::store::g_mutex.unlock();
  843. return EmoteCollectionOutcome::notReady;
  844. }
  845. bool changed = false;
  846. bool failed = false;
  847. for (std::size_t characterIndex = 0; characterIndex < candidate.characterCount && !failed;
  848. ++characterIndex) {
  849. CharacterState& character = candidate.characters[characterIndex];
  850. auto& collectionSlot = character.equipment.slots[kEmoteCollectionSlot];
  851. const bool present =
  852. collectionSlot.has_value()
  853. && collectionSlot->definitionHash == authored_inventory::kEmoteCollectionDefinitionHash;
  854. if (present && socket_state_sound(*collectionSlot, collectionDefinition.definitionIndex)) {
  855. continue;
  856. }
  857. if (character.nextInventorySerial
  858. >= static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())) {
  859. failed = true;
  860. break;
  861. }
  862. // A repair owns the definition, the sockets and the serial. Every other field, the
  863. // item-state flags above all, belongs to the player and survives.
  864. authored_inventory::Item granted = present ? *collectionSlot : authored_inventory::Item{};
  865. if (!present) {
  866. std::uint64_t instanceSoid = 0;
  867. if (!next_item_instance_soid(candidate, instanceSoid)) {
  868. failed = true;
  869. break;
  870. }
  871. granted.instanceSoid = instanceSoid;
  872. granted.level = 0;
  873. granted.quantity = 1;
  874. }
  875. granted.definitionHash = authored_inventory::kEmoteCollectionDefinitionHash;
  876. granted.mutationSerial = static_cast<std::int32_t>(character.nextInventorySerial++);
  877. // Replaced whole: lanes past the used prefix must be empty for the block to validate.
  878. granted.sockets = authored_inventory::Sockets{};
  879. granted.sockets.policy = authored_inventory::SocketPolicy::authored;
  880. granted.sockets.plugCount = kEmoteCollectionDefaultPlugHashes.size();
  881. for (std::size_t lane = 0; lane < kEmoteCollectionDefaultPlugHashes.size(); ++lane) {
  882. granted.sockets.plugs[lane] = kEmoteCollectionDefaultPlugHashes[lane];
  883. }
  884. collectionSlot = granted;
  885. changed = true;
  886. }
  887. if (failed) {
  888. investment::store::g_mutex.unlock();
  889. return EmoteCollectionOutcome::failed;
  890. }
  891. if (!changed) {
  892. investment::store::g_mutex.unlock();
  893. return EmoteCollectionOutcome::ready;
  894. }
  895. if (!account::valid(candidate)) {
  896. investment::store::g_mutex.unlock();
  897. return EmoteCollectionOutcome::failed;
  898. }
  899. if (!investment::store::write_account(candidate)) {
  900. investment::store::g_mutex.unlock();
  901. return EmoteCollectionOutcome::failed;
  902. }
  903. investment::store::g_mutex.unlock();
  904. return EmoteCollectionOutcome::ready;
  905. }
  906. } // namespace sunrise::state