Explorar o código

Implement collectible rewards and seasonal progression

Millie hai 1 semana
pai
achega
128ebbbe1a

+ 29 - 0
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -17,6 +17,7 @@
 #include <limits>
 
 #include "../../../../state/build_data/runtime.h"
+#include "../../../../state/progression/seasonal_experience.h"
 #include "../../../../state/unlocks/unlocks_runtime.h"
 #include "../progression/progression_bank_keys.h"
 #include "layout.h"
@@ -171,6 +172,34 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
                                object.progressions)) {
         return false;
     }
+    // Account progression 40 is the Season of Arrivals XP progression. Lane zero is cumulative
+    // progression progress, so publish the persisted seasonal XP without synthesizing a rank.
+    constexpr std::uint16_t kSeasonalExperienceDefinitionIndex = 40U;
+    const std::int32_t earnedExperience = state::progression::seasonal_experience::earned();
+    for (std::size_t slot = 0; slot < object.progressions.size(); ++slot) {
+        progression::layout::Entry& entry = object.progressions[slot];
+        if (entry.definitionIndex != kSeasonalExperienceDefinitionIndex) {
+            continue;
+        }
+        entry.values[0] = (std::max)(entry.values[0], earnedExperience);
+        static std::atomic<bool> reported{false};
+        if (!reported.exchange(true, std::memory_order_relaxed)) {
+            std::array<char, 160> line{};
+            const int written = std::snprintf(line.data(),
+                                              line.size(),
+                                              "ev=season_xp stage=encode definition=40 slot=%zu "
+                                              "progress=%d",
+                                              slot,
+                                              entry.values[0]);
+            if (written > 0) {
+                core::log::write(core::log::Channel::middleware,
+                                 core::log::Level::info,
+                                 {line.data(),
+                                  (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
+            }
+        }
+        break;
+    }
     // Profile rows are sentinelled above, so placement only has to claim its own slots.
     std::array<std::uint16_t, kBucketIdentityCapacity> takenSlots{};
     for (std::size_t index = 0; index < state.profileItemCount; ++index) {

+ 73 - 0
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -12,6 +12,7 @@
 #include <limits>
 #include <optional>
 
+#include "../../../../state/build_data/runtime.h"
 #include "../../../../state/unlocks/unlocks_runtime.h"
 #include "../instance/layout.h"
 #include "../progression/progression_bank_keys.h"
@@ -30,6 +31,18 @@ constexpr std::byte kSeenMessageByte{0xFF};
 constexpr std::uint8_t kNativeTrue = 1;
 /** Native 1-byte booleans encode false as 0. */
 constexpr std::uint8_t kNativeFalse = 0;
+/** Stackable quest items needed by the collectible interactions currently supported. */
+struct CollectibleQuest {
+    std::uint32_t definitionHash{};
+    /** Lore completion flag which consumes this quest, or zero for a permanent test item. */
+    std::uint16_t completionFlag{};
+};
+constexpr std::array<CollectibleQuest, 4> kCollectibleQuests{{
+    {0x57C4540AU, 0U},     // A Small Gift
+    {0x85CC476EU, 10762U}, // Adonna's Quest -> Gimble-4's Ghost
+    {0xB099029AU, 10766U}, // A Futile Search -> Lonesome Ghost
+    {0xC3535D63U, 10769U}, // A Loyal Friend -> Vell Tarlowe's Ghost
+}};
 
 /**
  * Validates the authored fields consumed by the selected-character encoder.
@@ -52,6 +65,63 @@ constexpr std::size_t kBitsPerFlagByte = 8;
  */
 constexpr std::int32_t kOccupiedRowWatermark = 1;
 
+/**
+ * Publishes collectible prerequisites in their installed character-inventory quest bucket.
+ * Stackable quest rows have no instance SOID and therefore need no Family-4 item residents.
+ */
+[[nodiscard]] bool place_collectible_quest_items(layout::Object& object) noexcept {
+    std::optional<std::uint8_t> questBucketId;
+    std::size_t nextRow = 0;
+    std::size_t rowLimit = 0;
+    for (const CollectibleQuest& quest : kCollectibleQuests) {
+        if (quest.completionFlag != 0
+            && (state::record_claims::claimed(quest.completionFlag)
+                || state::record_claims::claimable(quest.completionFlag))) {
+            continue;
+        }
+        state::build_data::items::Definition item{};
+        state::build_data::items::details::Definition detail{};
+        state::build_data::inventory::buckets::Descriptor bucket{};
+        if (!state::build_data::find_item_definition_hash(quest.definitionHash, item)
+            || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)
+            || detail.definitionIndex != item.definitionIndex
+            || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
+            || detail.instancedDefinitionState
+                   != state::build_data::items::details::InstancedDefinitionState::stackable
+            || detail.maxStackSize < 1 || detail.equipmentSlot.has_value()
+            || !state::build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)
+            || bucket.arraySelector
+                   != state::build_data::inventory::buckets::ArraySelector::character
+            || bucket.slotCount == 0 || bucket.firstSlot >= object.inventoryItems.size()
+            || bucket.slotCount > object.inventoryItems.size() - bucket.firstSlot) {
+            return false;
+        }
+        if (!questBucketId.has_value()) {
+            questBucketId = item.bucketId;
+            nextRow = bucket.firstSlot;
+            rowLimit = bucket.firstSlot + bucket.slotCount;
+        } else if (*questBucketId != item.bucketId || nextRow >= rowLimit) {
+            return false;
+        }
+        while (nextRow < rowLimit
+               && object.inventoryItems[nextRow].definitionIndex != kEmptyDefinitionIndex) {
+            ++nextRow;
+        }
+        if (nextRow >= rowLimit) {
+            return false;
+        }
+
+        inventory::layout::Entry& row = object.inventoryItems[nextRow];
+        row.definitionIndex = item.definitionIndex;
+        row.quantity = 1;
+        object.newItemFlags[nextRow / kBitsPerFlagByte] |=
+            std::byte{1U} << (nextRow % kBitsPerFlagByte);
+        object.instanceProgressWatermarks[nextRow] = kOccupiedRowWatermark;
+        ++nextRow;
+    }
+    return true;
+}
+
 /**
  * Validates every inventory and equipment field consumed by the character object.
  * @param resolvedLoadout Candidate row-sorted installed mapping.
@@ -212,6 +282,9 @@ bool encode(const state::CharacterState& state,
             object.equippedInstanceSoids[item.equipmentSlot] = item.instance.instanceSoid;
         }
     }
+    if (!place_collectible_quest_items(object)) {
+        return false;
+    }
 
     // Commit only after validation so callers never receive a partially initialized object.
     std::fill(output.begin(), output.end(), std::byte{});

+ 17 - 0
Sunrise/src/server/bap/bap_route.cpp

@@ -225,6 +225,23 @@ bool arm_world_item_acquisition(state::PendingItemAcquisition acquisition) noexc
     return false;
 }
 
+bool arm_world_profile_item_acquisition(
+    state::PendingProfileItemAcquisition acquisition) noexcept {
+    if (!acquisition.prepared) {
+        return false;
+    }
+    for (auto& peer : g_sessions) {
+        if (peer.id == 0 || !peer.authenticated || !peer.queuez.family4Active
+            || peer.worldProfileItemAcquisitionArmed) {
+            continue;
+        }
+        peer.pendingWorldProfileItemAcquisition = acquisition;
+        peer.worldProfileItemAcquisitionArmed = true;
+        return true;
+    }
+    return false;
+}
+
 
 /** Applies one serialized BAP connection lifecycle event. */
 bool consume(const client::network::BapRequest& request,

+ 228 - 25
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -11,6 +11,8 @@
 #include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/build_data/sobjects/sobject_catalog.h"
 #include "../../../../../state/lore/lore_grant.h"
+#include "../../../../../state/progression/seasonal_experience.h"
+#include "../../../../../state/record_claims/record_claims.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../../../bap/internal.h"
 #include "activity_message_receipts.h"
@@ -20,6 +22,7 @@
 #include <cstddef>
 #include <cstdint>
 #include <cstdio>
+#include <span>
 #include <string_view>
 
 #include "../../../../../core/logging/log.h"
@@ -94,55 +97,72 @@ struct EggResolution {
     bool resolved{};
 };
 
-struct EggLootResolution {
+struct WorldLootResolution {
     std::uint32_t definitionHash{};
     std::uint16_t definitionIndex{};
     bool granted{};
 };
 
-/** Grants one installed Dreaming City weapon or active-class Reverie Dawn armour piece. */
-[[nodiscard]] EggLootResolution grant_random_egg_loot() noexcept {
-    constexpr std::array<std::uint32_t, 7> kWeapons{
-        640114618U, 334171687U, 346136302U, 3242168339U,
-        3297863558U, 3740842661U, 1644162710U,
-    };
-    constexpr std::array<std::uint32_t, 5> kTitanArmour{
-        1472713738U, 1478378067U, 2561756285U, 4257800469U, 4023744176U,
-    };
-    constexpr std::array<std::uint32_t, 5> kHunterArmour{
-        2804026582U, 4008120231U, 2467635521U, 3185383401U, 844097260U,
-    };
-    constexpr std::array<std::uint32_t, 5> kWarlockArmour{
-        1076538039U, 150052158U, 757360370U, 569434520U, 1394177923U,
-    };
+/** Grants the nine Phantasmal Fragments paid by one completed Lost Ghost search. */
+[[nodiscard]] bool grant_lost_ghost_reward() noexcept {
+    constexpr std::uint32_t kPhantasmalFragmentHash = 443031982U;
+    constexpr std::int32_t kRewardQuantity = 9;
+    state::build_data::items::Definition definition{};
+    state::PendingProfileItemAcquisition acquisition{};
+    const bool prepared =
+        state::build_data::find_item_definition_hash(kPhantasmalFragmentHash, definition)
+        && state::prepare_profile_item_acquisition_for_item(
+            definition.definitionIndex, kRewardQuantity, acquisition);
+    const bool armed = prepared && bap::arm_world_profile_item_acquisition(acquisition);
+    report(armed ? core::log::Level::info : core::log::Level::warn,
+           "ev=activity stage=lost_ghost_reward result=%s hash=0x%08X quantity=%d",
+           armed ? "armed" : "fail",
+           kPhantasmalFragmentHash,
+           kRewardQuantity);
+    return armed;
+}
 
-    std::array<std::uint32_t, kWeapons.size() + kTitanArmour.size()> hashes{};
+/** Grants one installed world weapon or active-class armour piece from the supplied pool. */
+[[nodiscard]] WorldLootResolution grant_random_world_loot(
+    std::span<const std::uint32_t> weapons,
+    std::span<const std::uint32_t> titanArmour,
+    std::span<const std::uint32_t> hunterArmour,
+    std::span<const std::uint32_t> warlockArmour) noexcept {
+    constexpr std::size_t kMaximumWeaponCount = 9;
+    constexpr std::size_t kMaximumArmourCount = 5;
+    if (weapons.empty() || weapons.size() > kMaximumWeaponCount
+        || titanArmour.size() != kMaximumArmourCount
+        || hunterArmour.size() != kMaximumArmourCount
+        || warlockArmour.size() != kMaximumArmourCount) {
+        return {};
+    }
+    std::array<std::uint32_t, kMaximumWeaponCount + kMaximumArmourCount> hashes{};
     std::size_t hashCount = 0;
-    for (const std::uint32_t hash : kWeapons) {
+    for (const std::uint32_t hash : weapons) {
         hashes[hashCount++] = hash;
     }
     const state::AccountState account = state::account_snapshot();
-    const std::array<std::uint32_t, 5>* armour = nullptr;
+    std::span<const std::uint32_t> armour;
     for (std::size_t index = 0; index < account.characterCount; ++index) {
         if (!account.characters[index].selected) {
             continue;
         }
         switch (account.characters[index].characterClass) {
         case state::CharacterClass::hunter:
-            armour = &kHunterArmour;
+            armour = hunterArmour;
             break;
         case state::CharacterClass::warlock:
-            armour = &kWarlockArmour;
+            armour = warlockArmour;
             break;
         case state::CharacterClass::titan:
         default:
-            armour = &kTitanArmour;
+            armour = titanArmour;
             break;
         }
         break;
     }
-    if (armour != nullptr) {
-        for (const std::uint32_t hash : *armour) {
+    if (!armour.empty()) {
+        for (const std::uint32_t hash : armour) {
             hashes[hashCount++] = hash;
         }
     }
@@ -174,6 +194,122 @@ struct EggLootResolution {
     return {};
 }
 
+/** Grants one installed Dreaming City weapon or active-class Reverie Dawn armour piece. */
+[[nodiscard]] WorldLootResolution grant_random_dreaming_city_loot() noexcept {
+    constexpr std::array<std::uint32_t, 7> kWeapons{
+        640114618U, 334171687U, 346136302U, 3242168339U,
+        3297863558U, 3740842661U, 1644162710U,
+    };
+    constexpr std::array<std::uint32_t, 5> kTitanArmour{
+        1472713738U, 1478378067U, 2561756285U, 4257800469U, 4023744176U,
+    };
+    constexpr std::array<std::uint32_t, 5> kHunterArmour{
+        2804026582U, 4008120231U, 2467635521U, 3185383401U, 844097260U,
+    };
+    constexpr std::array<std::uint32_t, 5> kWarlockArmour{
+        1076538039U, 150052158U, 757360370U, 569434520U, 1394177923U,
+    };
+    return grant_random_world_loot(kWeapons, kTitanArmour, kHunterArmour, kWarlockArmour);
+}
+
+/** Grants one installed Moon weapon or active-class Dreambane armour piece. */
+[[nodiscard]] WorldLootResolution grant_random_moon_loot() noexcept {
+    constexpr std::array<std::uint32_t, 9> kWeapons{
+        2723909519U, 2931957300U, 3924212056U, 1016668089U, 1645386487U,
+        3325778512U, 4277547616U, 3870811754U, 3690523502U,
+    };
+    constexpr std::array<std::uint32_t, 5> kTitanArmour{
+        925079356U, 2568538788U, 3312368889U, 272413517U, 310888006U,
+    };
+    constexpr std::array<std::uint32_t, 5> kHunterArmour{
+        3571441640U, 883769696U, 193805725U, 659922705U, 377813570U,
+    };
+    constexpr std::array<std::uint32_t, 5> kWarlockArmour{
+        682780965U, 3692187003U, 2048903186U, 1528483180U, 1030110631U,
+    };
+    return grant_random_world_loot(kWeapons, kTitanArmour, kHunterArmour, kWarlockArmour);
+}
+
+/** Identifies the shared generic target as a Dreaming City cat-statue interaction. */
+[[nodiscard]] bool is_dreaming_city_cat(
+    std::uint32_t target,
+    bool definitionFound,
+    const state::build_data::sobjects::Definition& definition) noexcept {
+    constexpr std::uint32_t kGenericInteractionTarget = 3539U;
+    constexpr std::uint32_t kCatNameHash = 0x7A0FD954U;
+    constexpr std::uint32_t kCatLane4 = 0x0011FFFFU;
+    constexpr std::string_view kDreamingCityFreeroam = "dreaming_city_freeroam";
+    if (target != kGenericInteractionTarget || !definitionFound || definition.typeCode != 2
+        || definition.nameHash != kCatNameHash || definition.lane4 != kCatLane4) {
+        return false;
+    }
+
+    namespace activity = state::activity;
+    const std::uint64_t sessionId =
+        activity::membership::live_region_session(activity::kAbsentSessionId);
+    activity::destination::DestinationSelection selection{};
+    if (sessionId == activity::kAbsentSessionId
+        || !activity::destination::snapshot(sessionId, selection)) {
+        return false;
+    }
+    const std::string_view packageName{
+        reinterpret_cast<const char*>(selection.packageName.data()), selection.packageNameLength};
+    return packageName == kDreamingCityFreeroam;
+}
+
+/** Identifies a Jade Rabbit interaction by its generic target, statue ordinal, and Moon package. */
+[[nodiscard]] bool is_moon_rabbit(
+    const message::incident::Incident& incident,
+    bool primaryFound,
+    const state::build_data::sobjects::Definition& primary) noexcept {
+    constexpr std::uint32_t kGenericInteractionTarget = 3539U;
+    constexpr std::uint32_t kGenericNameHash = 0x7A0FD954U;
+    constexpr std::uint32_t kGenericLane4 = 0x0011FFFFU;
+    // The nine statues have different target indices and name hashes, but their type-code-2 world
+    // object ordinals form one dense run immediately before Luna's Lost ghosts.
+    constexpr std::uint16_t kFirstRabbitOrdinal = 3297U;
+    constexpr std::uint16_t kLastRabbitOrdinal = 3305U;
+    constexpr std::string_view kMoonFreeroam = "luna_freeroam";
+    if (incident.primaryTarget != kGenericInteractionTarget || !primaryFound
+        || primary.typeCode != 2 || primary.nameHash != kGenericNameHash
+        || primary.lane4 != kGenericLane4) {
+        return false;
+    }
+
+    bool hasRabbitTarget = false;
+    for (std::uint32_t index = 0; index < incident.extraTargetCount; ++index) {
+        const std::uint32_t target = incident.extraTargets[index];
+        if (target > (std::numeric_limits<std::uint16_t>::max)()) {
+            continue;
+        }
+        state::build_data::sobjects::Definition rabbit{};
+        if (!state::build_data::sobjects::find(static_cast<std::uint16_t>(target), rabbit)
+            || rabbit.typeCode != 2 || rabbit.recordRow() != 0xFFFFU) {
+            continue;
+        }
+        const std::uint16_t ordinal = rabbit.loreObjectOrdinal();
+        hasRabbitTarget = ordinal >= kFirstRabbitOrdinal && ordinal <= kLastRabbitOrdinal;
+        if (hasRabbitTarget) {
+            break;
+        }
+    }
+    if (!hasRabbitTarget) {
+        return false;
+    }
+
+    namespace activity = state::activity;
+    const std::uint64_t sessionId =
+        activity::membership::live_region_session(activity::kAbsentSessionId);
+    activity::destination::DestinationSelection selection{};
+    if (sessionId == activity::kAbsentSessionId
+        || !activity::destination::snapshot(sessionId, selection)) {
+        return false;
+    }
+    const std::string_view packageName{
+        reinterpret_cast<const char*>(selection.packageName.data()), selection.packageNameLength};
+    return packageName == kMoonFreeroam;
+}
+
 /** Reports and resolves the live world context for an incident whose packet has no egg id. */
 [[nodiscard]] EggResolution resolve_egg_context() noexcept {
     namespace activity = state::activity;
@@ -495,6 +631,14 @@ Framed frame_incident(const message::Request& request) noexcept {
                     constexpr std::uint16_t kBoneFirstOrdinal = 2517U;
                     constexpr std::uint16_t kBoneLastOrdinal = 2532U;
                     constexpr std::uint16_t kBoneFirstRecord = 759U;
+                    // The ten Luna's Lost ghosts are ordered exactly like their lore records.
+                    // The first nine advance the destination triumph; Vell Tarlowe is the later
+                    // Pit of Heresy ghost and does not belong to that 9-step objective.
+                    constexpr std::uint16_t kMoonGhostFirstOrdinal = 3310U;
+                    constexpr std::uint16_t kMoonGhostLastOrdinal = 3319U;
+                    constexpr std::uint16_t kMoonGhostFirstRecord = 1841U;
+                    constexpr std::uint16_t kMoonDestinationLastOrdinal = 3318U;
+                    constexpr std::uint16_t kLunasLostAreFoundFlag = 10698U;
                     const std::uint16_t ordinal = definition.loreObjectOrdinal();
                     std::uint16_t record = 0;
                     if (ordinal >= kDroneFirstOrdinal && ordinal <= kDroneLastOrdinal) {
@@ -508,10 +652,32 @@ Framed frame_incident(const message::Request& request) noexcept {
                     } else if (ordinal >= kBoneFirstOrdinal && ordinal <= kBoneLastOrdinal) {
                         record = static_cast<std::uint16_t>(
                             kBoneFirstRecord + ordinal - kBoneFirstOrdinal);
+                    } else if (ordinal >= kMoonGhostFirstOrdinal
+                               && ordinal <= kMoonGhostLastOrdinal) {
+                        record = static_cast<std::uint16_t>(
+                            kMoonGhostFirstRecord + ordinal - kMoonGhostFirstOrdinal);
                     } else {
                         return {};
                     }
                     const auto outcome = state::lore::grant_record(record);
+                    if (outcome == state::lore::GrantOutcome::granted
+                        && ordinal >= kMoonGhostFirstOrdinal
+                        && ordinal <= kMoonGhostLastOrdinal) {
+                        if (ordinal <= kMoonDestinationLastOrdinal) {
+                            (void)state::record_claims::advance_single_objective(
+                                kLunasLostAreFoundFlag);
+                        }
+                        (void)grant_lost_ghost_reward();
+                        constexpr std::int32_t kBaseExperienceReward = 2500;
+                        const bool experienceGranted =
+                            state::progression::seasonal_experience::grant(
+                                kBaseExperienceReward);
+                        report(experienceGranted ? core::log::Level::info
+                                                 : core::log::Level::warn,
+                               "ev=activity stage=lost_ghost_xp result=%s amount=%d",
+                               experienceGranted ? "granted" : "fail",
+                               kBaseExperienceReward);
+                    }
                     return {outcome, true};
                 }
                 return {};
@@ -536,6 +702,9 @@ Framed frame_incident(const message::Request& request) noexcept {
                                         && primary.typeCode == 3
                                         && primary.nameHash == kCorruptedEggNameHash
                                         && primary.lane4 == kCorruptedEggLane4;
+            const bool isDreamingCityCat =
+                is_dreaming_city_cat(parsed.primaryTarget, primaryFound, primary);
+            const bool isMoonRabbit = is_moon_rabbit(parsed, primaryFound, primary);
             if (resolution.outcome == state::lore::GrantOutcome::granted
                 || resolution.outcome == state::lore::GrantOutcome::progressed) {
                 bap::arm_account_resync_everywhere();
@@ -567,7 +736,7 @@ Framed frame_incident(const message::Request& request) noexcept {
                        exactFound ? exact.lanes[7] : 0U);
             } else if (isCorruptedEgg) {
                 const EggResolution egg = resolve_egg_context();
-                const EggLootResolution loot = grant_random_egg_loot();
+                const WorldLootResolution loot = grant_random_dreaming_city_loot();
                 if (egg.resolved
                     && (egg.outcome == state::lore::GrantOutcome::granted
                         || egg.outcome == state::lore::GrantOutcome::progressed)) {
@@ -605,6 +774,40 @@ Framed frame_incident(const message::Request& request) noexcept {
                                          {line.data(), length});
                     }
                 }
+            } else if (isDreamingCityCat) {
+                const WorldLootResolution loot = grant_random_dreaming_city_loot();
+                constexpr std::uint16_t kRememberYourMannersFlag = 9448U;
+                const state::record_claims::ObjectiveAdvance progress =
+                    state::record_claims::advance_single_objective(kRememberYourMannersFlag);
+                if (progress == state::record_claims::ObjectiveAdvance::advanced
+                    || progress == state::record_claims::ObjectiveAdvance::completed) {
+                    bap::arm_account_resync_everywhere();
+                }
+                report(core::log::Level::info,
+                       "ev=activity stage=loot path=cat target=%u result=%s "
+                       "item_hash=0x%08X item_index=%u progress=%u",
+                       parsed.primaryTarget,
+                       loot.granted ? "queued" : "failed",
+                       loot.definitionHash,
+                       loot.definitionIndex,
+                       static_cast<unsigned>(progress));
+            } else if (isMoonRabbit) {
+                const WorldLootResolution loot = grant_random_moon_loot();
+                constexpr std::uint16_t kLetThemEatRiceCakesFlag = 10696U;
+                const state::record_claims::ObjectiveAdvance progress =
+                    state::record_claims::advance_single_objective(kLetThemEatRiceCakesFlag);
+                if (progress == state::record_claims::ObjectiveAdvance::advanced
+                    || progress == state::record_claims::ObjectiveAdvance::completed) {
+                    bap::arm_account_resync_everywhere();
+                }
+                report(core::log::Level::info,
+                       "ev=activity stage=loot path=rabbit target=%u result=%s "
+                       "item_hash=0x%08X item_index=%u progress=%u",
+                       parsed.primaryTarget,
+                       loot.granted ? "queued" : "failed",
+                       loot.definitionHash,
+                       loot.definitionIndex,
+                       static_cast<unsigned>(progress));
             } else {
                 bool contextResolved = false;
                 if (parsed.primaryTarget == 3539U) {

+ 62 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -89,6 +89,62 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     return true;
 }
 
+/** Publishes and commits one profile material reward through its acquisition notification. */
+[[nodiscard]] bool consume_world_profile_item_acquisition(Session& session,
+                                                          Scratch& scratch,
+                                                          std::span<std::byte> response,
+                                                          std::size_t& written,
+                                                          bool& touchesScratch) noexcept {
+    if (!session.worldProfileItemAcquisitionArmed) {
+        return false;
+    }
+    touchesScratch = true;
+    queuez::ProfileItemAcquisition acquisition{};
+    const state::PendingProfileItemAcquisition pending =
+        session.pendingWorldProfileItemAcquisition;
+    if (!queuez::stage_profile_item_acquisition(session.queuez,
+                                                pending.accountSoid,
+                                                pending.acquiredInstanceSoid,
+                                                pending.actionSource,
+                                                pending.appended,
+                                                acquisition)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_profile_acquisition result=fail reason=stage");
+        return false;
+    }
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    if (!push::append_profile_item_acquisition_notification(scratch,
+                                                            acquisition,
+                                                            pending,
+                                                            state::bap().sessionKey,
+                                                            nextSendNonce,
+                                                            scratch.framed,
+                                                            framedSize)
+        || framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_profile_acquisition result=fail reason=encode");
+        return false;
+    }
+    if (!state::commit_profile_item_acquisition(session.pendingWorldProfileItemAcquisition)) {
+        session.worldProfileItemAcquisitionArmed = false;
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_profile_acquisition result=fail reason=commit");
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    middleware::secure_channel::advance_nonce(nextSendNonce);
+    session.sendNonce = nextSendNonce;
+    session.queuez = acquisition.after;
+    session.worldProfileItemAcquisitionArmed = false;
+    report_repush("world_profile_acquisition", framedSize);
+    return true;
+}
+
 /** Publishes the current account graph to a peer invalidated by another connection. */
 [[nodiscard]] bool consume_account_resync(Session& session,
                                           Scratch& scratch,
@@ -326,6 +382,12 @@ bool consume_deferred(Session& session,
     if (session.worldItemAcquisitionArmed) {
         return false;
     }
+    if (consume_world_profile_item_acquisition(session, scratch, response, written, touchesScratch)) {
+        return true;
+    }
+    if (session.worldProfileItemAcquisitionArmed) {
+        return false;
+    }
     if (consume_account_resync(session, scratch, response, written, touchesScratch)) {
         return true;
     }

+ 7 - 0
Sunrise/src/server/bap/internal.h

@@ -175,6 +175,9 @@ struct Session {
     /** Direct world reward waiting for its ordinary item-acquisition notification and commit. */
     state::PendingItemAcquisition pendingWorldItemAcquisition{};
     bool worldItemAcquisitionArmed{};
+    /** Direct world material reward waiting for its profile-acquisition notification and commit. */
+    state::PendingProfileItemAcquisition pendingWorldProfileItemAcquisition{};
+    bool worldProfileItemAcquisitionArmed{};
     /**
      * Tick count after which the owed ability-icon refresh may go out. A subclass selection
      * invalidates the published ability buckets and the rebuild runs off the Client
@@ -199,6 +202,10 @@ void arm_account_resync_everywhere() noexcept;
 [[nodiscard]] bool
 arm_world_item_acquisition(state::PendingItemAcquisition acquisition) noexcept;
 
+/** Queues one prepared profile material reward with ordinary acquisition feedback. */
+[[nodiscard]] bool arm_world_profile_item_acquisition(
+    state::PendingProfileItemAcquisition acquisition) noexcept;
+
 namespace plaintext {
 
 /**

+ 121 - 0
Sunrise/src/state/progression/seasonal_experience.cpp

@@ -0,0 +1,121 @@
+#include "seasonal_experience.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstdint>
+#include <cstring>
+#include <limits>
+#include <mutex>
+#include <string_view>
+
+#include "../../core/filesystem/path.h"
+
+namespace sunrise::state::progression::seasonal_experience {
+namespace {
+
+constexpr std::wstring_view kFileSuffix = L"\\cache\\seasonal_experience.bin";
+constexpr std::array<char, 8> kMagic{'S', 'N', 'R', 'S', 'X', 'P', '0', '1'};
+
+std::mutex g_lock;
+std::int32_t g_experience{};
+core::path::Buffer g_path{};
+bool g_pathReady{};
+
+void store_locked() noexcept {
+    if (!g_pathReady) {
+        return;
+    }
+    std::array<std::byte, kMagic.size() + sizeof(g_experience)> document{};
+    std::memcpy(document.data(), kMagic.data(), kMagic.size());
+    std::memcpy(document.data() + kMagic.size(), &g_experience, sizeof g_experience);
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_WRITE,
+                                    0,
+                                    nullptr,
+                                    CREATE_ALWAYS,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return;
+    }
+    DWORD written = 0;
+    (void)WriteFile(file,
+                    document.data(),
+                    static_cast<DWORD>(document.size()),
+                    &written,
+                    nullptr);
+    (void)CloseHandle(file);
+}
+
+void load_locked() noexcept {
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_READ,
+                                    FILE_SHARE_READ,
+                                    nullptr,
+                                    OPEN_EXISTING,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return;
+    }
+    std::array<std::byte, kMagic.size() + sizeof(g_experience)> document{};
+    DWORD read = 0;
+    const bool complete =
+        ReadFile(file,
+                 document.data(),
+                 static_cast<DWORD>(document.size()),
+                 &read,
+                 nullptr)
+            != FALSE
+        && read == document.size()
+        && std::memcmp(document.data(), kMagic.data(), kMagic.size()) == 0;
+    (void)CloseHandle(file);
+    std::int32_t restored = 0;
+    if (complete) {
+        std::memcpy(&restored, document.data() + kMagic.size(), sizeof restored);
+    }
+    if (restored >= 0) {
+        g_experience = restored;
+    }
+}
+
+} // namespace
+
+bool initialize(void* module) noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    g_experience = 0;
+    g_pathReady = core::path::artifact_directory(module, g_path)
+                  && core::path::append(g_path, kFileSuffix);
+    if (g_pathReady) {
+        load_locked();
+    }
+    return g_pathReady;
+}
+
+void shutdown() noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    g_experience = 0;
+    g_path = {};
+    g_pathReady = false;
+}
+
+bool grant(std::int32_t amount) noexcept {
+    if (amount <= 0) {
+        return false;
+    }
+    const std::lock_guard<std::mutex> guard(g_lock);
+    if (g_experience > (std::numeric_limits<std::int32_t>::max)() - amount) {
+        return false;
+    }
+    g_experience += amount;
+    store_locked();
+    return true;
+}
+
+std::int32_t earned() noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    return g_experience;
+}
+
+} // namespace sunrise::state::progression::seasonal_experience

+ 19 - 0
Sunrise/src/state/progression/seasonal_experience.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::state::progression::seasonal_experience {
+
+/** Resolves persistent storage and restores earned seasonal XP. */
+[[nodiscard]] bool initialize(void* module) noexcept;
+
+/** Clears process memory without deleting persisted XP. */
+void shutdown() noexcept;
+
+/** Adds unmodified base XP to the account's seasonal progression. */
+[[nodiscard]] bool grant(std::int32_t amount) noexcept;
+
+/** Returns runtime-earned seasonal XP. */
+[[nodiscard]] std::int32_t earned() noexcept;
+
+} // namespace sunrise::state::progression::seasonal_experience

+ 57 - 8
Sunrise/src/state/record_claims/record_claims.cpp

@@ -88,6 +88,27 @@ bool g_claimablePathReady{};
 core::path::Buffer g_progressPath{};
 bool g_progressPathReady{};
 
+/** Objective storage reserved by records whose installed definition exposes no objective rows. */
+struct ReservedObjective {
+    std::uint16_t flagIndex;
+    std::uint16_t firstSlot;
+    std::uint8_t slotCount;
+    std::int32_t completionValue;
+};
+
+constexpr std::array<ReservedObjective, 1> kReservedObjectives{{
+    {9448U, 3432U, 2U, 9}, // Remember Your Manners
+}};
+
+[[nodiscard]] const ReservedObjective*
+find_reserved_objective(std::uint16_t flagIndex) noexcept {
+    const auto found = std::find_if(
+        kReservedObjectives.begin(),
+        kReservedObjectives.end(),
+        [flagIndex](const ReservedObjective& objective) { return objective.flagIndex == flagIndex; });
+    return found != kReservedObjectives.end() ? &*found : nullptr;
+}
+
 [[nodiscard]] bool claimed_locked(std::uint16_t flagIndex) noexcept;
 [[nodiscard]] bool claimable_locked(std::uint16_t flagIndex) noexcept;
 
@@ -924,6 +945,19 @@ std::size_t apply_claimable_objectives(std::span<std::int32_t> objectiveValues)
             || found->objectiveCount != 1
             || static_cast<std::size_t>(found->firstObjective)
                    >= objective_slot_table::kObjectives.size()) {
+            const ReservedObjective* reserved = find_reserved_objective(flagIndex);
+            if (reserved == nullptr) {
+                continue;
+            }
+            for (std::uint8_t slot = 0; slot < reserved->slotCount; ++slot) {
+                const std::size_t valueSlot =
+                    static_cast<std::size_t>(reserved->firstSlot) + slot;
+                if (valueSlot < objectiveValues.size()) {
+                    objectiveValues[valueSlot] =
+                        std::min(g_progress[index], reserved->completionValue);
+                    ++written;
+                }
+            }
             continue;
         }
         const auto& objective = objective_slot_table::kObjectives[found->firstObjective];
@@ -950,7 +984,17 @@ std::size_t apply_claimable_objectives(std::span<std::int32_t> objectiveValues)
                     return entry.flagIndex < key;
                 });
             if (found == table.end() || found->flagIndex != flagIndex) {
-                // No objective slot for this record -- nothing this pass can write for it.
+                const ReservedObjective* reserved = find_reserved_objective(flagIndex);
+                if (reserved != nullptr) {
+                    for (std::uint8_t slot = 0; slot < reserved->slotCount; ++slot) {
+                        const std::size_t valueSlot =
+                            static_cast<std::size_t>(reserved->firstSlot) + slot;
+                        if (valueSlot < objectiveValues.size()) {
+                            objectiveValues[valueSlot] = reserved->completionValue;
+                            ++written;
+                        }
+                    }
+                }
                 continue;
             }
             for (std::uint8_t slot = 0; slot < found->objectiveCount; ++slot) {
@@ -1035,14 +1079,19 @@ ObjectiveAdvance advance_single_objective(std::uint16_t flagIndex) noexcept {
         [](const objective_slot_table::RecordEntry& entry, std::uint16_t key) {
             return entry.flagIndex < key;
         });
-    if (found == objective_slot_table::kRecords.end() || found->flagIndex != flagIndex
-        || found->objectiveCount != 1
-        || static_cast<std::size_t>(found->firstObjective)
-               >= objective_slot_table::kObjectives.size()) {
-        return ObjectiveAdvance::unavailable;
+    std::int32_t completion = 0;
+    if (found != objective_slot_table::kRecords.end() && found->flagIndex == flagIndex
+        && found->objectiveCount == 1
+        && static_cast<std::size_t>(found->firstObjective)
+               < objective_slot_table::kObjectives.size()) {
+        completion = objective_slot_table::kObjectives[found->firstObjective].completionValue;
+    } else {
+        const ReservedObjective* reserved = find_reserved_objective(flagIndex);
+        if (reserved == nullptr) {
+            return ObjectiveAdvance::unavailable;
+        }
+        completion = reserved->completionValue;
     }
-    const std::int32_t completion =
-        objective_slot_table::kObjectives[found->firstObjective].completionValue;
     if (completion <= 0) {
         return ObjectiveAdvance::unavailable;
     }

+ 3 - 2
Sunrise/src/state/record_claims/record_claims.h

@@ -6,7 +6,7 @@
 
 namespace sunrise::state::record_claims {
 
-/** Result of advancing a record that has one authored objective. */
+/** Result of advancing a record that has one authored or mapped reserved objective. */
 enum class ObjectiveAdvance : std::uint8_t {
     /** No single objective mapping exists for the supplied completion flag. */
     unavailable,
@@ -60,7 +60,8 @@ void clear() noexcept;
 [[nodiscard]] bool mark_claimable(std::uint16_t flagIndex) noexcept;
 
 /**
- * Advances a record's sole objective by one and persists the partial value.
+ * Advances a record's sole objective by one and persists the partial value. Records whose native
+ * definitions reserve value slots without exposing objective rows use their measured slot map.
  * At the authored completion value the partial row is replaced by claimable state.
  */
 [[nodiscard]] ObjectiveAdvance advance_single_objective(std::uint16_t flagIndex) noexcept;

+ 37 - 0
Sunrise/src/state/runtime/state_runtime.cpp

@@ -2,6 +2,7 @@
 #include "../build_data/records/rewards/reward_persistence.h"
 #include "../build_data/nodes/node_persistence.h"
 #include "../record_claims/record_claims.h"
+#include "../progression/seasonal_experience.h"
 #include <Windows.h>
 
 #include <algorithm>
@@ -39,6 +40,31 @@ constexpr std::uint32_t kDefaultTokenLifetimeSeconds = 3600;
 /** Family 5 uses the largest signed 64-bit value as its process-global object key. */
 constexpr std::uint64_t kGlobalFamily5Soid =
     static_cast<std::uint64_t>((std::numeric_limits<std::int64_t>::max)());
+/** Global unlock-value slot named by the installed build's season constants. */
+constexpr std::uint16_t kActiveSeasonValueSlot = 607;
+/** One-based season number carried by the Season of Arrivals definition. */
+constexpr std::int32_t kSeasonOfArrivalsNumber = 11;
+
+/**
+ * Makes one process-owned global value authoritative without disturbing authored overrides.
+ * @return False only when a new row is needed and the bounded family-5 list is full.
+ */
+[[nodiscard]] bool upsert_family5_value(Family5State& family,
+                                        std::uint16_t slot,
+                                        std::int32_t value) noexcept {
+    for (std::size_t index = 0; index < family.valueCount; ++index) {
+        if (family.values[index].slot == slot) {
+            family.values[index].value = value;
+            return true;
+        }
+    }
+    if (family.valueCount >= family.values.size()) {
+        return false;
+    }
+    family.values[family.valueCount++] = UnlockValueOverride{slot, value};
+    return true;
+}
+
 /**
  * Fills fixed secret storage with Windows system randomness.
  * @tparam Size Required secret byte count.
@@ -217,6 +243,7 @@ bool initialize(void* module,
     // same as the claim-index and lore-node tables above.
     (void)build_data::records::rewards::initialize(module);
     (void)record_claims::initialize(module);
+    (void)progression::seasonal_experience::initialize(module);
     // A cache hit already has the complete plug relation, so publish canonical profile identities
     // in the first State image.  On a first cache build, snapshot preparation repeats this step
     // after package extraction has published the relation.
@@ -262,6 +289,15 @@ bool initialize(void* module,
     initialized.investment.family5.flagCount = authored.flagCount;
     initialized.investment.family5.values = authored.values;
     initialized.investment.family5.valueCount = authored.valueCount;
+    // Family 5 addresses the Client's global unlock-value space directly. Slot 607 selects the
+    // season definition; account objective rows use a separate mapped index space and cannot.
+    if (!upsert_family5_value(initialized.investment.family5,
+                              kActiveSeasonValueSlot,
+                              kSeasonOfArrivalsNumber)) {
+        SecureZeroMemory(&initialized, sizeof initialized);
+        build_data::shutdown();
+        return false;
+    }
     // The arm is account-wide and rides the first ws-503, which goes out before any pick. Nothing
     // is selected at boot, so it is armed when any authored character carries the bypass. The
     // per-character objB byte is the other half, and it still decides which character it opens.
@@ -285,6 +321,7 @@ void shutdown() noexcept {
     AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
     SecureZeroMemory(&runtime::storage::g_state, sizeof runtime::storage::g_state);
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    progression::seasonal_experience::shutdown();
     build_data::shutdown();
 }