|
@@ -1,4 +1,4 @@
|
|
|
-#include <Windows.h>
|
|
|
|
|
|
|
+#include <Windows.h>
|
|
|
|
|
|
|
|
#include <algorithm>
|
|
#include <algorithm>
|
|
|
#include <array>
|
|
#include <array>
|
|
@@ -24,6 +24,7 @@ namespace authored_inventory = account::inventory;
|
|
|
namespace item_details = build_data::items::details;
|
|
namespace item_details = build_data::items::details;
|
|
|
namespace inventory_buckets = build_data::inventory::buckets;
|
|
namespace inventory_buckets = build_data::inventory::buckets;
|
|
|
namespace family4_loadout = middleware::datagen::family4::loadout;
|
|
namespace family4_loadout = middleware::datagen::family4::loadout;
|
|
|
|
|
+namespace socket_lists = build_data::socket_entry_lists;
|
|
|
|
|
|
|
|
/** Writes one exhaustive equipment-transaction checkpoint to the persistent diagnostic log. */
|
|
/** Writes one exhaustive equipment-transaction checkpoint to the persistent diagnostic log. */
|
|
|
void report_equipment(std::string_view stage,
|
|
void report_equipment(std::string_view stage,
|
|
@@ -105,6 +106,150 @@ void report_acquisition(std::string_view stage,
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Prepares a subclass ability-entry transition without publishing account State.
|
|
|
|
|
+ * The requested entry must currently compete (share a socket-entry group) with exactly one of the
|
|
|
|
|
+ * character's 5 authored ability picks; that pick is the one the transition updates. This mirrors
|
|
|
|
|
+ * how `resolve_socket_states` decides which entries a selection makes active, so the entry a
|
|
|
|
|
+ * request names always maps back to the same field that selection would have set.
|
|
|
|
|
+ */
|
|
|
|
|
+[[nodiscard]] bool stage_subclass_selection(const AccountState& snapshot,
|
|
|
|
|
+ std::size_t characterIndex,
|
|
|
|
|
+ std::uint64_t subclassInstanceSoid,
|
|
|
|
|
+ std::uint8_t requestedEntry,
|
|
|
|
|
+ PendingSubclassSelection& mutation) noexcept {
|
|
|
|
|
+ mutation = {};
|
|
|
|
|
+ if (!account::valid(snapshot) || characterIndex >= snapshot.characterCount
|
|
|
|
|
+ || subclassInstanceSoid == 0 || requestedEntry >= socket_lists::kEntryCapacity) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ const CharacterState& before = snapshot.characters[characterIndex];
|
|
|
|
|
+ if (!before.selected || before.soid == 0) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ constexpr std::size_t kSubclassSlot =
|
|
|
|
|
+ static_cast<std::size_t>(authored_inventory::EquipmentSlot::subclass);
|
|
|
|
|
+ const auto& subclass = before.equipment.slots[kSubclassSlot];
|
|
|
|
|
+ build_data::items::Definition subclassDefinition{};
|
|
|
|
|
+ item_details::Definition detail{};
|
|
|
|
|
+ socket_lists::EntryTable entries{};
|
|
|
|
|
+ if (!subclass.has_value() || subclass->instanceSoid != subclassInstanceSoid
|
|
|
|
|
+ || !build_data::find_item_definition_hash(subclass->definitionHash, subclassDefinition)
|
|
|
|
|
+ || !build_data::find_configured_item_detail(subclassDefinition.definitionIndex, detail)
|
|
|
|
|
+ || !build_data::find_socket_entry_table(detail.socketEntryListIndex, entries)
|
|
|
|
|
+ || requestedEntry >= entries.entries.size()) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const socket_lists::Entry& requested = entries.entries[requestedEntry];
|
|
|
|
|
+ if (requested.plugSource == socket_lists::kNoPlugSource
|
|
|
|
|
+ || requested.group == socket_lists::kNoEntryGroup) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // A clicked entry's table position does not say which ability slot it fills; only its
|
|
|
|
|
+ // resolved destination bucket does. A bundled pick (an Attunement, for example) can mix its
|
|
|
|
|
+ // members freely across slots, so every member in the clicked entry's bundle is checked, not
|
|
|
|
|
+ // just the one clicked.
|
|
|
|
|
+ CharacterState after = before;
|
|
|
|
|
+ // The picks belong to the equipped subclass item itself, not the character, so each owned
|
|
|
|
|
+ // subclass remembers its own selection independently instead of sharing one set across all
|
|
|
|
|
+ // of them.
|
|
|
|
|
+ auto& afterSubclass = after.equipment.slots[kSubclassSlot];
|
|
|
|
|
+ struct Route {
|
|
|
|
|
+ std::uint8_t bucket;
|
|
|
|
|
+ std::uint8_t* field;
|
|
|
|
|
+ std::uint8_t defaultEntry;
|
|
|
|
|
+ };
|
|
|
|
|
+ const std::array<Route, 5> routes{{
|
|
|
|
|
+ {kMovementAbilityBucket, &afterSubclass->movementAbilityEntry, kDefaultMovementAbilityEntry},
|
|
|
|
|
+ {kGrenadeAbilityBucket, &afterSubclass->grenadeAbilityEntry, kDefaultGrenadeAbilityEntry},
|
|
|
|
|
+ {kSuperAbilityBucket, &afterSubclass->superAbilityEntry, kDefaultSuperAbilityEntry},
|
|
|
|
|
+ {kMeleeAbilityBucket, &afterSubclass->meleeAbilityEntry, kDefaultMeleeAbilityEntry},
|
|
|
|
|
+ {class_ability_bucket(after.characterClass), &afterSubclass->classAbilityEntry,
|
|
|
|
|
+ kDefaultClassAbilityEntry},
|
|
|
|
|
+ }};
|
|
|
|
|
+ const auto bucket_of = [&](std::uint8_t entryIndex) noexcept {
|
|
|
|
|
+ std::uint8_t bucket = build_data::socket_entry_buckets::kNoDestinationBucket;
|
|
|
|
|
+ (void)build_data::find_socket_entry_bucket(
|
|
|
|
|
+ detail.socketEntryListIndex, entryIndex, bucket);
|
|
|
|
|
+ return bucket;
|
|
|
|
|
+ };
|
|
|
|
|
+ const auto route_entry = [&](std::uint8_t entryIndex) noexcept {
|
|
|
|
|
+ const std::uint8_t bucket = bucket_of(entryIndex);
|
|
|
|
|
+ for (const Route& route : routes) {
|
|
|
|
|
+ if (route.bucket == bucket) {
|
|
|
|
|
+ *route.field = entryIndex;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ // A click can land on any member of a bundle, not only the routable one: the diamond's other
|
|
|
|
|
+ // 3 quadrants are passive nodes with no destination bucket of their own (see the group-3
|
|
|
|
|
+ // dump: only one member of each 4-node group resolves to melee, or to super and melee both).
|
|
|
|
|
+ // The requested entry is only ever the whole bundle's anchor when it happens to be its lowest
|
|
|
|
|
+ // index, so the bundle's true start is found by scanning backward first, then every member is
|
|
|
|
|
+ // routed from there. Members share the anchor's group only while a wide group (a bundle, not
|
|
|
|
|
+ // a simple set of alternatives) is in play; see resolve_socket_states for the same threshold.
|
|
|
|
|
+ std::size_t groupPopulation = 0;
|
|
|
|
|
+ for (std::size_t index = 0; index < entries.entries.size(); ++index) {
|
|
|
|
|
+ if (entries.entries[index].group == requested.group) {
|
|
|
|
|
+ ++groupPopulation;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (groupPopulation <= kMaxAttunementBundleSize) {
|
|
|
|
|
+ route_entry(requestedEntry);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // A wide group is several same-sized bundles competing for one pick, not several
|
|
|
|
|
+ // independent alternatives, so only one bundle's fields stay set at a time. A bundle that
|
|
|
|
|
+ // does not touch every field this group can reach (the top and bottom Attunement options
|
|
|
|
|
+ // here do not touch super, only the middle one does) must not leave an earlier bundle's
|
|
|
|
|
+ // value behind in the field it left alone: super stuck on a prior Attunement's pick while
|
|
|
|
|
+ // melee moves to a different one is a combination the game never produces on its own, and
|
|
|
|
|
+ // it stops accepting further picks once state reaches it. Every bucket this whole group
|
|
|
|
|
+ // can ever reach is reset to its ordinary default first, and only then does the picked
|
|
|
|
|
+ // bundle's own members overwrite the ones it actually claims.
|
|
|
|
|
+ for (std::size_t index = 0; index < entries.entries.size(); ++index) {
|
|
|
|
|
+ if (entries.entries[index].group != requested.group) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const std::uint8_t bucket = bucket_of(static_cast<std::uint8_t>(index));
|
|
|
|
|
+ for (const Route& route : routes) {
|
|
|
|
|
+ if (route.bucket == bucket) {
|
|
|
|
|
+ *route.field = route.defaultEntry;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ std::uint8_t blockStart = requestedEntry;
|
|
|
|
|
+ while (blockStart > 0 && requestedEntry - blockStart < kMaxAttunementBundleSize - 1
|
|
|
|
|
+ && entries.entries[blockStart - 1].group == requested.group) {
|
|
|
|
|
+ --blockStart;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (std::size_t offset = 0; offset < kMaxAttunementBundleSize
|
|
|
|
|
+ && blockStart + offset < entries.entries.size()
|
|
|
|
|
+ && entries.entries[blockStart + offset].group == requested.group;
|
|
|
|
|
+ ++offset) {
|
|
|
|
|
+ route_entry(static_cast<std::uint8_t>(blockStart + offset));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (same_character(before, after)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ mutation.beforeCharacter = before;
|
|
|
|
|
+ mutation.afterCharacter = after;
|
|
|
|
|
+ mutation.accountSoid = snapshot.primarySoid;
|
|
|
|
|
+ mutation.characterSoid = before.soid;
|
|
|
|
|
+ mutation.subclassInstanceSoid = subclassInstanceSoid;
|
|
|
|
|
+ mutation.subclassDefinitionHash = subclassDefinition.definitionHash;
|
|
|
|
|
+ mutation.characterIndex = characterIndex;
|
|
|
|
|
+ mutation.subclassDefinitionIndex = subclassDefinition.definitionIndex;
|
|
|
|
|
+ mutation.socketEntryListIndex = detail.socketEntryListIndex;
|
|
|
|
|
+ mutation.requestedEntry = requestedEntry;
|
|
|
|
|
+ mutation.prepared = true;
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
} // namespace runtime::detail
|
|
} // namespace runtime::detail
|
|
|
|
|
|
|
|
using namespace runtime::detail;
|
|
using namespace runtime::detail;
|
|
@@ -486,6 +631,15 @@ bool commit_equipment_swap(PendingEquipmentSwap& mutation) noexcept {
|
|
|
runtime::storage::g_state.account = candidate;
|
|
runtime::storage::g_state.account = candidate;
|
|
|
ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
|
|
ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
|
|
|
|
|
|
|
|
|
|
+ // The published ability buckets are resolved against whichever subclass is currently
|
|
|
|
|
+ // equipped; swapping that item away makes the domain stale the same way an ability-entry
|
|
|
|
|
+ // pick does, so it needs the same invalidation or the character screen keeps showing
|
|
|
|
|
+ // whatever the previous subclass resolved to until something else happens to refresh it.
|
|
|
|
|
+ if (prepared.equipmentSlotIndex
|
|
|
|
|
+ == static_cast<std::size_t>(authored_inventory::EquipmentSlot::subclass)) {
|
|
|
|
|
+ build_data::invalidate_ability_buckets();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
report_equipment("commit_end",
|
|
report_equipment("commit_end",
|
|
|
"ok",
|
|
"ok",
|
|
|
prepared.kind,
|
|
prepared.kind,
|
|
@@ -509,4 +663,82 @@ AccountState account_snapshot() noexcept {
|
|
|
return snapshot;
|
|
return snapshot;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** Grants each character the other 2 subclasses of its equipped subclass's class. */
|
|
|
|
|
+bool ensure_character_subclasses() noexcept {
|
|
|
|
|
+ constexpr std::size_t kSubclassSlot =
|
|
|
|
|
+ static_cast<std::size_t>(authored_inventory::EquipmentSlot::subclass);
|
|
|
|
|
+ AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
|
|
|
|
|
+ AccountState candidate = runtime::storage::g_state.account;
|
|
|
|
|
+ if (!account::valid(candidate)) {
|
|
|
|
|
+ ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ std::uint64_t nextSoid = 0;
|
|
|
|
|
+ bool haveNextSoid = false;
|
|
|
|
|
+ bool changed = false;
|
|
|
|
|
+ bool failed = false;
|
|
|
|
|
+ for (std::size_t characterIndex = 0;
|
|
|
|
|
+ characterIndex < candidate.characterCount && !failed;
|
|
|
|
|
+ ++characterIndex) {
|
|
|
|
|
+ CharacterState& character = candidate.characters[characterIndex];
|
|
|
|
|
+ const std::optional<authored_inventory::Item>& equipped =
|
|
|
|
|
+ character.equipment.slots[kSubclassSlot];
|
|
|
|
|
+ if (!equipped.has_value()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ build_data::items::Definition equippedDefinition{};
|
|
|
|
|
+ std::array<std::uint16_t, build_data::kSubclassGroupSize> group{};
|
|
|
|
|
+ if (!build_data::find_item_definition_hash(equipped->definitionHash, equippedDefinition)
|
|
|
|
|
+ || !build_data::find_subclass_group(equippedDefinition.definitionIndex, group)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const std::uint16_t memberIndex : group) {
|
|
|
|
|
+ if (memberIndex == equippedDefinition.definitionIndex) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ build_data::items::Definition memberDefinition{};
|
|
|
|
|
+ if (!build_data::find_item_definition_index(memberIndex, memberDefinition)
|
|
|
|
|
+ || memberDefinition.definitionIndex != memberIndex) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ bool present = false;
|
|
|
|
|
+ for (std::size_t itemIndex = 0; itemIndex < character.inventory.count; ++itemIndex) {
|
|
|
|
|
+ if (character.inventory.values[itemIndex].definitionHash
|
|
|
|
|
+ == memberDefinition.definitionHash) {
|
|
|
|
|
+ present = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (present || character.inventory.count >= character.inventory.values.size()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!haveNextSoid) {
|
|
|
|
|
+ if (!next_item_instance_soid(candidate, nextSoid)) {
|
|
|
|
|
+ failed = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ haveNextSoid = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ authored_inventory::Item granted{};
|
|
|
|
|
+ granted.instanceSoid = nextSoid++;
|
|
|
|
|
+ granted.definitionHash = memberDefinition.definitionHash;
|
|
|
|
|
+ granted.level = 0;
|
|
|
|
|
+ granted.quantity = 1;
|
|
|
|
|
+ // Every resolved item's serial must stay behind the character's own counter (checked
|
|
|
|
|
+ // by the character encoder, not by account::valid), so claim the next one here too.
|
|
|
|
|
+ granted.mutationSerial = static_cast<std::int32_t>(character.nextInventorySerial++);
|
|
|
|
|
+ granted.sockets.policy = authored_inventory::SocketPolicy::nativeDefaults;
|
|
|
|
|
+ character.inventory.values[character.inventory.count++] = granted;
|
|
|
|
|
+ changed = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (failed || !changed || !account::valid(candidate)) {
|
|
|
|
|
+ ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
|
|
|
|
|
+ return !failed;
|
|
|
|
|
+ }
|
|
|
|
|
+ runtime::storage::g_state.account = candidate;
|
|
|
|
|
+ ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
} // namespace sunrise::state
|
|
} // namespace sunrise::state
|