state_account_acquisition_runtime.cpp 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847
  1. #include <Windows.h>
  2. #include <algorithm>
  3. #include <array>
  4. #include <cstddef>
  5. #include <cstdint>
  6. #include <limits>
  7. #include "../../middleware/datagen/family4/loadout/loadout_resolver.h"
  8. #include "../build_data/runtime.h"
  9. #include "../investment/store_internal.h"
  10. #include "runtime.h"
  11. #include "state_account_transaction_helpers.h"
  12. #include "storage/internal.h"
  13. namespace sunrise::state {
  14. using namespace runtime::detail;
  15. namespace authored_inventory = account::inventory;
  16. namespace item_details = build_data::items::details;
  17. namespace inventory_buckets = build_data::inventory::buckets;
  18. namespace family4_loadout = middleware::datagen::family4::loadout;
  19. namespace runtime::detail {
  20. using Quest = build_data::items::QuestInitialization;
  21. /**
  22. * The plan must already be valid and nonempty before selecting a save bank.
  23. * @param quest First-step plan with account or character scope.
  24. * @return The persistent value bank for that scope.
  25. */
  26. [[nodiscard]] investment::store::Bank quest_bank(const Quest& quest) noexcept {
  27. return quest.scope == Quest::Scope::account ? investment::store::Bank::objectiveValues
  28. : investment::store::Bank::characterObjectValues;
  29. }
  30. /**
  31. * Hold investment::store::g_mutex and validate the selected character before this check.
  32. * @param mutation Prepared acquisition with the prior saved quest value.
  33. * @return True only while item metadata and the saved quest value still match.
  34. */
  35. [[nodiscard]] bool quest_current(const PendingItemAcquisition& mutation) noexcept {
  36. build_data::items::Definition definition{};
  37. if (!build_data::find_item_definition_hash(mutation.acquiredDefinitionHash, definition)
  38. || definition.questInitialization != mutation.questInitialization
  39. || !build_data::items::valid(mutation.questInitialization)) {
  40. return false;
  41. }
  42. if (mutation.questInitialization.scope == Quest::Scope::none) {
  43. return mutation.previousQuestValue == build_data::items::kUnsetQuestValue;
  44. }
  45. std::int32_t current = 0;
  46. return investment::store::read_unlock(
  47. quest_bank(mutation.questInitialization), mutation.questInitialization.row, current)
  48. && current == mutation.previousQuestValue;
  49. }
  50. /** @return The selected character's index, or the character count when none is selected. */
  51. [[nodiscard]] std::size_t selected_character_index(const AccountState& account) noexcept {
  52. const std::size_t count = (std::min)(account.characterCount, account.characters.size());
  53. for (std::size_t index = 0; index < count; ++index) {
  54. if (account.characters[index].selected) {
  55. return index;
  56. }
  57. }
  58. return account.characters.size();
  59. }
  60. /**
  61. * Hold investment::store::g_mutex while capturing inventory and quest state together.
  62. * @param account State before any acquisition charge.
  63. * @param chargedAccount State after the prepared material charge.
  64. * @param definitionHash Item definition to grant.
  65. * @param profileChanged Whether the charge changed profile inventory.
  66. * @param source Grant identity and material requirements for commit checks.
  67. * @param mutation Receives a pending grant; use only on success.
  68. * @return False when the item, inventory, mapping, or saved quest state is invalid.
  69. */
  70. [[nodiscard]] bool finalize_item_acquisition(const AccountState& account,
  71. const AccountState& chargedAccount,
  72. std::uint32_t definitionHash,
  73. bool profileChanged,
  74. const GrantSource& source,
  75. PendingItemAcquisition& mutation) noexcept {
  76. const std::size_t characterIndex = selected_character_index(account);
  77. if (characterIndex >= account.characterCount) {
  78. return false;
  79. }
  80. const CharacterState& before = account.characters[characterIndex];
  81. if (before.inventory.count >= before.inventory.values.size()
  82. || before.nextInventorySerial
  83. >= static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())) {
  84. return false;
  85. }
  86. std::uint64_t instanceSoid = 0;
  87. if (!next_item_instance_soid(account, instanceSoid)) {
  88. return false;
  89. }
  90. CharacterState after = before;
  91. const std::size_t inventoryIndex = after.inventory.count;
  92. authored_inventory::Item acquired{};
  93. acquired.instanceSoid = instanceSoid;
  94. acquired.definitionHash = definitionHash;
  95. acquired.level = acquisition_level(before);
  96. acquired.quantity = 1;
  97. acquired.mutationSerial = static_cast<std::int32_t>(after.nextInventorySerial++);
  98. acquired.sockets.policy = authored_inventory::SocketPolicy::nativeDefaults;
  99. after.inventory.values[inventoryIndex] = acquired;
  100. ++after.inventory.count;
  101. AccountState candidate = chargedAccount;
  102. candidate.characters[characterIndex] = after;
  103. family4_loadout::ResolvedLoadout resolved{};
  104. std::uint16_t inventoryRow = 0;
  105. std::uint8_t equipmentSlot = 0;
  106. if (!account::valid(candidate) || identity_uses_soid(candidate, instanceSoid)
  107. || !family4_loadout::resolve(candidate, characterIndex, resolved)
  108. || !find_unequipped_row(resolved, instanceSoid, inventoryRow, equipmentSlot)) {
  109. return false;
  110. }
  111. mutation.beforeCharacter = before;
  112. mutation.afterCharacter = after;
  113. mutation.beforeProfileItems = account.profileItems;
  114. mutation.afterProfileItems = chargedAccount.profileItems;
  115. mutation.accountSoid = account.primarySoid;
  116. mutation.characterSoid = before.soid;
  117. mutation.acquiredInstanceSoid = instanceSoid;
  118. mutation.acquiredDefinitionHash = definitionHash;
  119. mutation.materialRequirementSetHash = source.materialRequirementSetHash;
  120. mutation.characterIndex = characterIndex;
  121. mutation.expectedInventoryCount = before.inventory.count;
  122. mutation.expectedProfileItemCount = account.profileItemCount;
  123. mutation.afterProfileItemCount = chargedAccount.profileItemCount;
  124. mutation.inventoryIndex = inventoryIndex;
  125. mutation.collectibleIndex = source.collectibleIndex;
  126. mutation.inventoryRow = inventoryRow;
  127. mutation.equipmentSlot = equipmentSlot;
  128. mutation.materialRequirementCount = source.materialRequirementCount;
  129. mutation.profileChanged = profileChanged;
  130. mutation.directGrant = source.direct;
  131. build_data::items::Definition definition{};
  132. if (!build_data::find_item_definition_hash(definitionHash, definition)
  133. || !build_data::items::valid(definition.questInitialization)) {
  134. return false;
  135. }
  136. mutation.questInitialization = definition.questInitialization;
  137. if (mutation.questInitialization.scope != Quest::Scope::none
  138. && !investment::store::read_unlock(quest_bank(mutation.questInitialization),
  139. mutation.questInitialization.row,
  140. mutation.previousQuestValue)) {
  141. return false;
  142. }
  143. mutation.prepared = true;
  144. return true;
  145. }
  146. } // namespace runtime::detail
  147. namespace {
  148. /** A vendor row pays its own price; a Collections pull pays the collectible's materials. */
  149. [[nodiscard]] bool
  150. charge_acquisition(const AccountState& account,
  151. std::optional<std::span<const build_data::vendors::SaleCost>> price,
  152. const build_data::collectibles::Definition* collectible,
  153. AccountState& charged,
  154. bool& changed) noexcept {
  155. if (price.has_value()) {
  156. return apply_sale_price(account, *price, charged, changed);
  157. }
  158. return collectible == nullptr
  159. || apply_collection_materials(account, *collectible, charged, changed);
  160. }
  161. } // namespace
  162. /**
  163. * Inventory and quest state must come from the same locked save view.
  164. * @param collectibleIndex Collections row, or kNoCollectibleIndex for an item-only grant.
  165. * @param definitionHash Item definition to grant.
  166. * @param price A vendor row's cost, spent in place of the collectible's materials; or absent.
  167. * @param mutation Receives a pending grant; prepared is set only on success.
  168. * @return False when identity, costs, capacity, or saved state prevent the grant.
  169. */
  170. bool prepare_item_acquisition(std::uint16_t collectibleIndex,
  171. std::uint32_t definitionHash,
  172. std::optional<std::span<const build_data::vendors::SaleCost>> price,
  173. PendingItemAcquisition& mutation) noexcept {
  174. const std::lock_guard lock(investment::store::g_mutex);
  175. mutation = {};
  176. const AccountState account = account_snapshot();
  177. build_data::collectibles::Definition collectible{};
  178. build_data::items::Definition grantedDefinition{};
  179. // A vendor row may name an item with no collectible, so the collectible steps are skipped
  180. // rather than faked. The item is still validated, just by its own hash.
  181. const bool hasCollectible = collectibleIndex != build_data::collectibles::kNoCollectibleIndex;
  182. if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
  183. || !valid_profile_inventory(account)) {
  184. return false;
  185. }
  186. if (hasCollectible) {
  187. if (!build_data::find_collectible_definition(collectibleIndex, collectible)
  188. || collectible.itemDefinitionIndex
  189. == build_data::collectibles::kUnavailableItemDefinitionIndex
  190. || !build_data::find_item_definition_index(collectible.itemDefinitionIndex,
  191. grantedDefinition)
  192. || grantedDefinition.definitionHash != definitionHash) {
  193. return false;
  194. }
  195. } else if (!build_data::find_item_definition_hash(definitionHash, grantedDefinition)
  196. || grantedDefinition.definitionHash != definitionHash) {
  197. return false;
  198. }
  199. AccountState chargedAccount = account;
  200. bool profileChanged = false;
  201. if (!charge_acquisition(account,
  202. price,
  203. hasCollectible ? &collectible : nullptr,
  204. chargedAccount,
  205. profileChanged)) {
  206. return false;
  207. }
  208. // Commit re-checks the collectible's cost fields; a sale price is proven by the profile images.
  209. return finalize_item_acquisition(
  210. account,
  211. chargedAccount,
  212. definitionHash,
  213. profileChanged,
  214. {.materialRequirementSetHash = collectible.materialRequirementSetHash,
  215. .collectibleIndex = collectibleIndex,
  216. .materialRequirementCount = collectible.materialRequirementCount},
  217. mutation);
  218. }
  219. /**
  220. * Direct grants share quest-state checks but do not charge Collections materials.
  221. * @param itemDefinitionIndex Item-table row to grant to the selected character.
  222. * @param mutation Receives a pending grant; prepared is set only on success.
  223. * @return False when the item, inventory, mapping, or saved quest state is invalid.
  224. */
  225. bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
  226. PendingItemAcquisition& mutation) noexcept {
  227. const std::lock_guard lock(investment::store::g_mutex);
  228. mutation = {};
  229. const AccountState account = account_snapshot();
  230. build_data::items::Definition grantedDefinition{};
  231. if (!account::valid(account) || !valid_profile_inventory(account)
  232. || !build_data::find_item_definition_index(itemDefinitionIndex, grantedDefinition)
  233. || grantedDefinition.definitionHash == authored_inventory::kNoDefinitionHash) {
  234. return false;
  235. }
  236. return finalize_item_acquisition(
  237. account, account, grantedDefinition.definitionHash, false, {.direct = true}, mutation);
  238. }
  239. /** Prepares a fixed Season wrapper expansion without exposing a partial package. */
  240. bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash,
  241. std::span<const std::uint16_t> itemDefinitionIndices,
  242. PendingDirectItemBundle& mutation) noexcept {
  243. mutation = {};
  244. build_data::season_pass::Package package{};
  245. if (!build_data::find_season_pass_package(sourceDefinitionHash, package)
  246. || itemDefinitionIndices.size() != package.itemCount) {
  247. return false;
  248. }
  249. std::array<std::uint32_t, build_data::season_pass::kPackageItemCapacity> hashes{};
  250. for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) {
  251. const std::uint16_t definitionIndex = itemDefinitionIndices[index];
  252. build_data::items::Definition definition{};
  253. item_details::Definition detail{};
  254. inventory_buckets::Descriptor bucket{};
  255. if (!build_data::find_item_definition_index(definitionIndex, definition)
  256. || definition.definitionHash != package.items[index]
  257. || !build_data::find_configured_item_detail(definitionIndex, detail)
  258. || detail.definitionIndex != definition.definitionIndex
  259. || detail.definitionHash != definition.definitionHash
  260. || detail.bucketId != definition.bucketId
  261. || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced
  262. || !detail.equipmentSlot.has_value()
  263. || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)
  264. || bucket.arraySelector != inventory_buckets::ArraySelector::character) {
  265. return false;
  266. }
  267. hashes[index] = definition.definitionHash;
  268. }
  269. const AccountState account = account_snapshot();
  270. const std::size_t characterIndex = selected_character_index(account);
  271. if (!account::valid(account) || characterIndex >= account.characterCount) {
  272. return false;
  273. }
  274. const CharacterState& before = account.characters[characterIndex];
  275. if (before.nextInventorySerial
  276. > static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())
  277. || itemDefinitionIndices.size() > before.inventory.values.size() - before.inventory.count
  278. || itemDefinitionIndices.size()
  279. > static_cast<std::size_t>((std::numeric_limits<std::int32_t>::max)())
  280. - before.nextInventorySerial) {
  281. return false;
  282. }
  283. std::uint64_t firstSoid = 0;
  284. if (!next_item_instance_soid(account, firstSoid)
  285. || itemDefinitionIndices.size() - 1U
  286. > (std::numeric_limits<std::uint64_t>::max)() - firstSoid) {
  287. return false;
  288. }
  289. CharacterState after = before;
  290. const std::int32_t level = acquisition_level(before);
  291. for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) {
  292. authored_inventory::Item granted{};
  293. granted.instanceSoid = firstSoid + index;
  294. granted.definitionHash = hashes[index];
  295. granted.level = level;
  296. granted.quantity = 1;
  297. granted.mutationSerial = static_cast<std::int32_t>(after.nextInventorySerial++);
  298. after.inventory.values[after.inventory.count++] = granted;
  299. }
  300. AccountState candidate = account;
  301. candidate.characters[characterIndex] = after;
  302. family4_loadout::ResolvedLoadout resolved{};
  303. if (!account::valid(candidate)
  304. || !family4_loadout::resolve(candidate, characterIndex, resolved)) {
  305. return false;
  306. }
  307. for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) {
  308. std::uint16_t row = 0;
  309. std::uint8_t slot = 0;
  310. if (!find_unequipped_row(resolved, firstSoid + index, row, slot)) {
  311. return false;
  312. }
  313. }
  314. mutation.beforeCharacter = before;
  315. mutation.afterCharacter = after;
  316. mutation.accountSoid = account.primarySoid;
  317. mutation.characterSoid = before.soid;
  318. mutation.firstInstanceSoid = firstSoid;
  319. mutation.sourceDefinitionHash = sourceDefinitionHash;
  320. mutation.characterIndex = characterIndex;
  321. mutation.expectedInventoryCount = before.inventory.count;
  322. mutation.itemCount = itemDefinitionIndices.size();
  323. mutation.prepared = true;
  324. return true;
  325. }
  326. /**
  327. * Takes the selected character's next inventory mutation serial under the State lock.
  328. * @param mutationSerial Receives the reserved serial; zero when nothing was reserved.
  329. * @return False when no character is selected or its serial space is exhausted.
  330. */
  331. bool reserve_selected_character_inventory_serial(std::int32_t& mutationSerial) noexcept {
  332. mutationSerial = 0;
  333. investment::store::g_mutex.lock();
  334. AccountState account = investment::store::account();
  335. const std::size_t characterIndex = selected_character_index(account);
  336. bool ready = account::valid(account) && characterIndex < account.characterCount
  337. && account.characters[characterIndex].nextInventorySerial
  338. < static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)());
  339. if (ready) {
  340. mutationSerial =
  341. static_cast<std::int32_t>(account.characters[characterIndex].nextInventorySerial++);
  342. }
  343. if (ready) {
  344. ready = investment::store::write_account(account);
  345. }
  346. if (!ready) {
  347. mutationSerial = 0;
  348. }
  349. investment::store::g_mutex.unlock();
  350. return ready;
  351. }
  352. namespace runtime::detail {
  353. /**
  354. * Checks that a prepared insertion still agrees with its Collections row or direct grant.
  355. * @return False when the mutation's shape, its cost fields, or its item no longer hold.
  356. */
  357. [[nodiscard]] static bool
  358. valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept {
  359. if (!mutation.prepared || mutation.characterSoid == 0 || mutation.acquiredInstanceSoid == 0
  360. || mutation.accountSoid == 0
  361. || mutation.acquiredDefinitionHash == authored_inventory::kNoDefinitionHash
  362. || mutation.characterIndex >= kCharacterCapacity
  363. || mutation.expectedInventoryCount >= authored_inventory::kCharacterItemCapacity
  364. || mutation.inventoryIndex != mutation.expectedInventoryCount
  365. || mutation.afterCharacter.inventory.count != mutation.expectedInventoryCount + 1U
  366. || mutation.inventoryIndex >= mutation.afterCharacter.inventory.count
  367. || mutation.afterCharacter.inventory.values[mutation.inventoryIndex].instanceSoid
  368. != mutation.acquiredInstanceSoid
  369. || mutation.afterCharacter.inventory.values[mutation.inventoryIndex].definitionHash
  370. != mutation.acquiredDefinitionHash
  371. || mutation.expectedProfileItemCount > authored_inventory::kProfileItemCapacity
  372. || mutation.afterProfileItemCount > authored_inventory::kProfileItemCapacity) {
  373. return false;
  374. }
  375. if (mutation.directGrant) {
  376. build_data::items::Definition definition{};
  377. return mutation.collectibleIndex == 0 && mutation.materialRequirementSetHash == 0
  378. && mutation.materialRequirementCount == 0
  379. && same_profile_views(mutation.beforeProfileItems,
  380. mutation.expectedProfileItemCount,
  381. mutation.afterProfileItems,
  382. mutation.afterProfileItemCount)
  383. && build_data::find_item_definition_hash(mutation.acquiredDefinitionHash,
  384. definition);
  385. }
  386. if (mutation.collectibleIndex == build_data::collectibles::kNoCollectibleIndex) {
  387. // The guard is that prepare and commit agree. Without a collectible they agree on there
  388. // being none, which means both cost fields must still be clear.
  389. build_data::items::Definition definition{};
  390. return mutation.materialRequirementSetHash == 0 && mutation.materialRequirementCount == 0
  391. && build_data::find_item_definition_hash(mutation.acquiredDefinitionHash,
  392. definition);
  393. }
  394. build_data::collectibles::Definition collectible{};
  395. build_data::items::Definition definition{};
  396. return build_data::find_collectible_definition(mutation.collectibleIndex, collectible)
  397. && collectible.itemDefinitionIndex
  398. != build_data::collectibles::kUnavailableItemDefinitionIndex
  399. && collectible.materialRequirementSetHash == mutation.materialRequirementSetHash
  400. && collectible.materialRequirementCount == mutation.materialRequirementCount
  401. && build_data::find_item_definition_index(collectible.itemDefinitionIndex, definition)
  402. && definition.definitionHash == mutation.acquiredDefinitionHash;
  403. }
  404. /**
  405. * Hold investment::store::g_mutex; the selected character and saved state must still match.
  406. * @param current Current account from the locked save view.
  407. * @param mutation Prepared inventory insertion and prior quest state.
  408. * @param after Receives the candidate account; use only on success.
  409. * @return False for stale state or an invalid resulting inventory.
  410. */
  411. [[nodiscard]] bool materialize_item_acquisition(const AccountState& current,
  412. const PendingItemAcquisition& mutation,
  413. AccountState& after) noexcept {
  414. std::uint64_t nextSoid = 0;
  415. if (!valid_item_acquisition_source(mutation)
  416. || mutation.characterIndex >= current.characterCount
  417. || !current.characters[mutation.characterIndex].selected
  418. || current.characters[mutation.characterIndex].soid != mutation.characterSoid
  419. || !quest_current(mutation) || current.primarySoid != mutation.accountSoid
  420. || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter)
  421. || !same_profile_inventory(
  422. current, mutation.beforeProfileItems, mutation.expectedProfileItemCount)
  423. || !next_item_instance_soid(current, nextSoid)
  424. || nextSoid != mutation.acquiredInstanceSoid) {
  425. return false;
  426. }
  427. after = current;
  428. after.profileItems = mutation.afterProfileItems;
  429. after.profileItemCount = mutation.afterProfileItemCount;
  430. after.characters[mutation.characterIndex] = mutation.afterCharacter;
  431. family4_loadout::ResolvedLoadout resolved{};
  432. std::uint16_t row = 0;
  433. std::uint8_t slot = 0;
  434. return account::valid(after) && valid_profile_inventory(after)
  435. && family4_loadout::resolve(after, mutation.characterIndex, resolved)
  436. && find_unequipped_row(resolved, mutation.acquiredInstanceSoid, row, slot)
  437. && row == mutation.inventoryRow && slot == mutation.equipmentSlot;
  438. }
  439. /** Rebuilds one package from installed policy and rejects any altered after-image. */
  440. [[nodiscard]] bool materialize_direct_item_bundle(const AccountState& current,
  441. const PendingDirectItemBundle& mutation,
  442. AccountState& after) noexcept {
  443. build_data::season_pass::Package package{};
  444. std::uint64_t firstSoid = 0;
  445. if (!mutation.prepared
  446. || !build_data::find_season_pass_package(mutation.sourceDefinitionHash, package)
  447. || mutation.itemCount != package.itemCount || mutation.accountSoid == 0
  448. || mutation.characterSoid == 0 || mutation.firstInstanceSoid == 0
  449. || mutation.characterIndex >= current.characterCount
  450. || mutation.expectedInventoryCount >= authored_inventory::kCharacterItemCapacity
  451. || current.primarySoid != mutation.accountSoid
  452. || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter)
  453. || !current.characters[mutation.characterIndex].selected
  454. || current.characters[mutation.characterIndex].soid != mutation.characterSoid
  455. || mutation.beforeCharacter.inventory.count != mutation.expectedInventoryCount
  456. || mutation.itemCount
  457. > mutation.beforeCharacter.inventory.values.size() - mutation.expectedInventoryCount
  458. || mutation.beforeCharacter.nextInventorySerial
  459. > static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())
  460. || mutation.itemCount > static_cast<std::size_t>((std::numeric_limits<std::int32_t>::max)())
  461. - mutation.beforeCharacter.nextInventorySerial
  462. || !next_item_instance_soid(current, firstSoid) || firstSoid != mutation.firstInstanceSoid
  463. || mutation.itemCount - 1U > (std::numeric_limits<std::uint64_t>::max)() - firstSoid) {
  464. return false;
  465. }
  466. CharacterState canonical = mutation.beforeCharacter;
  467. const std::int32_t level = acquisition_level(canonical);
  468. for (std::size_t index = 0; index < mutation.itemCount; ++index) {
  469. build_data::items::Definition definition{};
  470. item_details::Definition detail{};
  471. inventory_buckets::Descriptor bucket{};
  472. if (!build_data::find_item_definition_hash(package.items[index], definition)
  473. || !build_data::find_configured_item_detail(definition.definitionIndex, detail)
  474. || detail.definitionHash != definition.definitionHash
  475. || detail.definitionIndex != definition.definitionIndex
  476. || detail.bucketId != definition.bucketId
  477. || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced
  478. || !detail.equipmentSlot.has_value()
  479. || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)
  480. || bucket.arraySelector != inventory_buckets::ArraySelector::character) {
  481. return false;
  482. }
  483. authored_inventory::Item granted{};
  484. granted.instanceSoid = firstSoid + index;
  485. granted.definitionHash = package.items[index];
  486. granted.level = level;
  487. granted.quantity = 1;
  488. granted.mutationSerial = static_cast<std::int32_t>(canonical.nextInventorySerial++);
  489. canonical.inventory.values[canonical.inventory.count++] = granted;
  490. }
  491. if (!same_character(canonical, mutation.afterCharacter)) {
  492. return false;
  493. }
  494. after = current;
  495. after.characters[mutation.characterIndex] = canonical;
  496. family4_loadout::ResolvedLoadout resolved{};
  497. if (!account::valid(after)
  498. || !family4_loadout::resolve(after, mutation.characterIndex, resolved)) {
  499. return false;
  500. }
  501. for (std::size_t index = 0; index < mutation.itemCount; ++index) {
  502. std::uint16_t row = 0;
  503. std::uint8_t slot = 0;
  504. if (!find_unequipped_row(resolved, firstSoid + index, row, slot)) {
  505. return false;
  506. }
  507. }
  508. return true;
  509. }
  510. } // namespace runtime::detail
  511. /**
  512. * Preview inventory and quest values together without changing the save.
  513. * @param mutation Prepared acquisition checked against current saved state.
  514. * @param after Receives the candidate account; use only on success.
  515. * @param afterUnlocks Receives matching account and selected-character unlocks on success.
  516. * @return False when the acquisition is stale or its saved unlocks cannot be read.
  517. */
  518. bool preview_item_acquisition(const PendingItemAcquisition& mutation,
  519. AccountState& after,
  520. unlocks::Table& afterUnlocks) noexcept {
  521. const std::lock_guard lock(investment::store::g_mutex);
  522. after = {};
  523. afterUnlocks = {};
  524. if (!materialize_item_acquisition(account_snapshot(), mutation, after)
  525. || !investment::store::read_unlocks(afterUnlocks,
  526. static_cast<int>(mutation.characterIndex))) {
  527. return false;
  528. }
  529. const auto& quest = mutation.questInitialization;
  530. const auto value = build_data::items::initialized_value(quest, mutation.previousQuestValue);
  531. if (quest.scope == Quest::Scope::account) {
  532. afterUnlocks.objectiveValues[quest.row] = value;
  533. } else if (quest.scope == Quest::Scope::character) {
  534. afterUnlocks.characterObjectValues[quest.row] = value;
  535. }
  536. return true;
  537. }
  538. /** Produces the full account after-image while a prepared package remains current. */
  539. bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation,
  540. AccountState& after) noexcept {
  541. after = {};
  542. return materialize_direct_item_bundle(account_snapshot(), mutation, after);
  543. }
  544. /**
  545. * Inventory and first-step state share one transaction; failure rolls both back.
  546. * @param mutation Prepared grant consumed on either success or failure.
  547. * @return True when both writes commit against the unchanged prepared state.
  548. */
  549. bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept {
  550. const PendingItemAcquisition& prepared = mutation;
  551. const PendingConsumption consume{mutation};
  552. investment::store::Transaction transaction;
  553. AccountState candidate{};
  554. if (!transaction.ready()
  555. || !materialize_item_acquisition(investment::store::account(), prepared, candidate)
  556. || !investment::store::write_account(candidate)) {
  557. return false;
  558. }
  559. const auto& quest = prepared.questInitialization;
  560. if (quest.scope != Quest::Scope::none
  561. && prepared.previousQuestValue == build_data::items::kUnsetQuestValue
  562. && !investment::store::write_unlock(quest_bank(quest), quest.row, quest.value)) {
  563. return false;
  564. }
  565. return transaction.commit();
  566. }
  567. namespace runtime::detail {
  568. /**
  569. * Resolves one stackable profile-bucket item with its configured detail row.
  570. * @param item Receives the item definition.
  571. * @param detail Receives the configured detail row.
  572. * @return False when the two rows disagree or the item is not a stackable profile item.
  573. */
  574. [[nodiscard]] static bool resolve_profile_item(std::uint16_t itemDefinitionIndex,
  575. build_data::items::Definition& item,
  576. item_details::Definition& detail) noexcept {
  577. inventory_buckets::Descriptor bucket{};
  578. return build_data::find_item_definition_index(itemDefinitionIndex, item)
  579. && item.definitionHash != authored_inventory::kNoDefinitionHash
  580. && build_data::find_configured_item_detail(itemDefinitionIndex, detail)
  581. && detail.definitionIndex == item.definitionIndex
  582. && detail.definitionHash == item.definitionHash && detail.bucketId == item.bucketId
  583. && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable
  584. && detail.maxStackSize > 0
  585. && build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)
  586. && bucket.arraySelector == inventory_buckets::ArraySelector::profile;
  587. }
  588. /** Stages the common profile-stack insertion path. */
  589. [[nodiscard]] bool
  590. finalize_profile_item_acquisition(const AccountState& account,
  591. const AccountState& chargedAccount,
  592. std::uint32_t definitionHash,
  593. const item_details::Definition& detail,
  594. bool actionSource,
  595. std::int32_t quantity,
  596. const GrantSource& source,
  597. PendingProfileItemAcquisition& mutation) noexcept {
  598. if (quantity <= 0 || quantity > detail.maxStackSize) {
  599. return false;
  600. }
  601. std::size_t profileIndex = chargedAccount.profileItemCount;
  602. std::int32_t previousQuantity = 0;
  603. std::int32_t previousMutationSerial = 0;
  604. std::int32_t greatestMutationSerial = 0;
  605. bool appended = true;
  606. for (std::size_t index = 0; index < account.profileItemCount; ++index) {
  607. greatestMutationSerial =
  608. (std::max)(greatestMutationSerial, account.profileItems[index].mutationSerial);
  609. }
  610. for (std::size_t index = 0; index < chargedAccount.profileItemCount; ++index) {
  611. const authored_inventory::ProfileItem& existing = chargedAccount.profileItems[index];
  612. greatestMutationSerial = (std::max)(greatestMutationSerial, existing.mutationSerial);
  613. if (existing.definitionHash != definitionHash) {
  614. continue;
  615. }
  616. if (existing.quantity > detail.maxStackSize) {
  617. return false;
  618. }
  619. if (appended && existing.quantity <= detail.maxStackSize - quantity) {
  620. profileIndex = index;
  621. previousQuantity = existing.quantity;
  622. previousMutationSerial = existing.mutationSerial;
  623. appended = false;
  624. }
  625. }
  626. if (greatestMutationSerial == (std::numeric_limits<std::int32_t>::max)()
  627. || (appended && chargedAccount.profileItemCount >= chargedAccount.profileItems.size())
  628. || quantity > detail.maxStackSize - previousQuantity) {
  629. return false;
  630. }
  631. std::uint64_t acquiredInstanceSoid =
  632. appended ? 0 : chargedAccount.profileItems[profileIndex].instanceSoid;
  633. if (!appended && actionSource != (acquiredInstanceSoid != 0)) {
  634. return false;
  635. }
  636. if (appended && actionSource
  637. && !next_profile_item_instance_soid(chargedAccount, acquiredInstanceSoid)) {
  638. return false;
  639. }
  640. AccountState after = chargedAccount;
  641. const std::int32_t acquiredMutationSerial = greatestMutationSerial + 1;
  642. if (appended) {
  643. after.profileItems[profileIndex] = {
  644. acquiredInstanceSoid, definitionHash, quantity, acquiredMutationSerial};
  645. ++after.profileItemCount;
  646. } else {
  647. after.profileItems[profileIndex].quantity += quantity;
  648. after.profileItems[profileIndex].mutationSerial = acquiredMutationSerial;
  649. }
  650. const std::int32_t acquiredQuantity = after.profileItems[profileIndex].quantity;
  651. if (after.profileItems[profileIndex].instanceSoid != acquiredInstanceSoid
  652. || acquiredQuantity <= previousQuantity || acquiredQuantity > detail.maxStackSize
  653. || !account::valid(after) || !valid_profile_inventory(after)) {
  654. return false;
  655. }
  656. mutation.beforeItems = account.profileItems;
  657. mutation.afterItems = after.profileItems;
  658. mutation.accountSoid = account.primarySoid;
  659. mutation.acquiredInstanceSoid = acquiredInstanceSoid;
  660. mutation.acquiredDefinitionHash = definitionHash;
  661. mutation.materialRequirementSetHash = source.materialRequirementSetHash;
  662. mutation.expectedItemCount = account.profileItemCount;
  663. mutation.afterItemCount = after.profileItemCount;
  664. mutation.profileIndex = profileIndex;
  665. mutation.previousQuantity = previousQuantity;
  666. mutation.acquiredQuantity = acquiredQuantity;
  667. mutation.previousMutationSerial = previousMutationSerial;
  668. mutation.acquiredMutationSerial = acquiredMutationSerial;
  669. mutation.collectibleIndex = source.collectibleIndex;
  670. mutation.bucketId = detail.bucketId;
  671. mutation.materialRequirementCount = source.materialRequirementCount;
  672. mutation.actionSource = actionSource;
  673. mutation.appended = appended;
  674. mutation.directGrant = source.direct;
  675. mutation.prepared = true;
  676. if (valid_profile_mutation_shape(mutation)) {
  677. return true;
  678. }
  679. mutation = {};
  680. return false;
  681. }
  682. } // namespace runtime::detail
  683. /**
  684. * Prepares one checked profile-stack increment or append.
  685. * @param price A vendor row's cost, spent in place of the collectible's materials; or absent.
  686. */
  687. bool prepare_profile_item_acquisition(
  688. std::uint16_t collectibleIndex,
  689. std::uint32_t definitionHash,
  690. std::optional<std::span<const build_data::vendors::SaleCost>> price,
  691. PendingProfileItemAcquisition& mutation) noexcept {
  692. mutation = {};
  693. const AccountState account = account_snapshot();
  694. build_data::collectibles::Definition collectible{};
  695. build_data::items::Definition item{};
  696. item_details::Definition detail{};
  697. if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
  698. || !valid_profile_inventory(account)
  699. || !build_data::find_item_definition_hash(definitionHash, item)
  700. // The item resolves first, because the collectible cross-check reads it. With no
  701. // collectible the item's own hash is the whole check.
  702. || (collectibleIndex != build_data::collectibles::kNoCollectibleIndex
  703. && (!build_data::find_collectible_definition(collectibleIndex, collectible)
  704. || collectible.itemDefinitionIndex
  705. == build_data::collectibles::kUnavailableItemDefinitionIndex
  706. || item.definitionIndex != collectible.itemDefinitionIndex))
  707. || !resolve_profile_item(item.definitionIndex, item, detail)
  708. || item.definitionHash != definitionHash) {
  709. return false;
  710. }
  711. AccountState chargedAccount = account;
  712. bool materialsChanged = false;
  713. const bool hasCollectible = collectibleIndex != build_data::collectibles::kNoCollectibleIndex;
  714. if (!charge_acquisition(account,
  715. price,
  716. hasCollectible ? &collectible : nullptr,
  717. chargedAccount,
  718. materialsChanged)) {
  719. return false;
  720. }
  721. const bool actionSource =
  722. build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
  723. // Commit re-checks the collectible's cost fields; a sale price is proven by the profile images.
  724. return finalize_profile_item_acquisition(
  725. account,
  726. chargedAccount,
  727. definitionHash,
  728. detail,
  729. actionSource,
  730. 1,
  731. {.materialRequirementSetHash = collectible.materialRequirementSetHash,
  732. .collectibleIndex = collectibleIndex,
  733. .materialRequirementCount = collectible.materialRequirementCount},
  734. mutation);
  735. }
  736. /** Prepares one direct profile-stack grant, with no Collections row or material charge. */
  737. bool prepare_profile_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
  738. std::int32_t quantity,
  739. PendingProfileItemAcquisition& mutation) noexcept {
  740. mutation = {};
  741. const AccountState account = account_snapshot();
  742. build_data::items::Definition item{};
  743. item_details::Definition detail{};
  744. if (quantity <= 0 || !account::valid(account) || !valid_profile_inventory(account)
  745. || !resolve_profile_item(itemDefinitionIndex, item, detail)) {
  746. return false;
  747. }
  748. const bool actionSource =
  749. build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
  750. return finalize_profile_item_acquisition(account,
  751. account,
  752. item.definitionHash,
  753. detail,
  754. actionSource,
  755. quantity,
  756. {.direct = true},
  757. mutation);
  758. }
  759. /** Produces the exact account after-image while the captured profile view is still current. */
  760. bool preview_profile_item_acquisition(const PendingProfileItemAcquisition& mutation,
  761. AccountState& after) noexcept {
  762. after = {};
  763. const AccountState current = account_snapshot();
  764. return materialize_profile_acquisition(current, mutation, after);
  765. }
  766. /** Commits one profile-stack after-image only while its exact prepare-time view remains
  767. * current. */
  768. bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcept {
  769. const PendingProfileItemAcquisition& prepared = mutation;
  770. const PendingConsumption consume{mutation};
  771. if (!valid_profile_mutation_shape(prepared)) {
  772. return false;
  773. }
  774. investment::store::g_mutex.lock();
  775. AccountState candidate{};
  776. const bool ready =
  777. materialize_profile_acquisition(investment::store::account(), prepared, candidate);
  778. if (ready) {
  779. if (!investment::store::write_account(candidate)) {
  780. investment::store::g_mutex.unlock();
  781. return false;
  782. }
  783. }
  784. investment::store::g_mutex.unlock();
  785. return ready;
  786. }
  787. } // namespace sunrise::state