Procházet zdrojové kódy

Map Dust collectibles and publish lore visibility

Millie před 1 týdnem
rodič
revize
96c1637dd8

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

@@ -143,6 +143,9 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
     // A node's progress bar reads a value slot and shows whatever it holds, so the claimed children
     // have to be counted into it here or the bar never moves.
     (void)state::record_claims::apply_node_progress(object.objectiveValues);
+    // Early lore chapters have a second visibility value separate from their completion objective.
+    // Publish it only for chapters the account actually holds, leaving undiscovered entries secret.
+    (void)state::record_claims::apply_chapter_visibility_gates(object.objectiveValues);
     // A record reads claimable when its objective equals completionValue and its flag is clear --
     // the flag alone can never carry that state, so its objective value(s) are written here instead.
     (void)state::record_claims::apply_claimable_objectives(object.objectiveValues);

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

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

+ 132 - 2
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -11,6 +11,7 @@
 #include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/build_data/sobjects/sobject_catalog.h"
 #include "../../../../../state/lore/lore_grant.h"
+#include "../../../../../state/runtime/runtime.h"
 #include "../../../../bap/internal.h"
 #include "activity_message_receipts.h"
 
@@ -29,6 +30,7 @@
 #include "../../../../../middleware/bap/activity_message/sense_update.h"
 #include "../../../../../middleware/bap/activity_message/start_activity.h"
 #include "../../../../../middleware/bap/activity_message/telemetry.h"
+#include "../../../../../middleware/crypto/random_bytes.h"
 #include "../../../../../middleware/encoding/byte_order.h"
 
 namespace sunrise::server::bap::encrypted::activity_message::receipts {
@@ -92,6 +94,86 @@ struct EggResolution {
     bool resolved{};
 };
 
+struct EggLootResolution {
+    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,
+    };
+
+    std::array<std::uint32_t, kWeapons.size() + kTitanArmour.size()> hashes{};
+    std::size_t hashCount = 0;
+    for (const std::uint32_t hash : kWeapons) {
+        hashes[hashCount++] = hash;
+    }
+    const state::AccountState account = state::account_snapshot();
+    const std::array<std::uint32_t, 5>* armour = nullptr;
+    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;
+            break;
+        case state::CharacterClass::warlock:
+            armour = &kWarlockArmour;
+            break;
+        case state::CharacterClass::titan:
+        default:
+            armour = &kTitanArmour;
+            break;
+        }
+        break;
+    }
+    if (armour != nullptr) {
+        for (const std::uint32_t hash : *armour) {
+            hashes[hashCount++] = hash;
+        }
+    }
+
+    std::array<std::byte, sizeof(std::uint32_t)> randomBytes{};
+    if (!middleware::crypto::random::fill(randomBytes)) {
+        return {};
+    }
+    std::uint32_t randomValue = 0;
+    for (std::size_t index = 0; index < randomBytes.size(); ++index) {
+        randomValue |= std::to_integer<std::uint32_t>(randomBytes[index]) << (index * 8U);
+    }
+    const std::size_t first = randomValue % hashCount;
+    for (std::size_t offset = 0; offset < hashCount; ++offset) {
+        const std::uint32_t hash = hashes[(first + offset) % hashCount];
+        state::build_data::items::Definition definition{};
+        if (!state::build_data::find_item_definition_hash(hash, definition)) {
+            continue;
+        }
+        state::PendingItemAcquisition acquisition{};
+        if (!state::prepare_item_acquisition_for_item(definition.definitionIndex, acquisition)) {
+            continue;
+        }
+        if (!bap::arm_world_item_acquisition(acquisition)) {
+            return {hash, definition.definitionIndex, false};
+        }
+        return {hash, definition.definitionIndex, true};
+    }
+    return {};
+}
+
 /** 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;
@@ -485,6 +567,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();
                 if (egg.resolved
                     && (egg.outcome == state::lore::GrantOutcome::granted
                         || egg.outcome == state::lore::GrantOutcome::progressed)) {
@@ -492,12 +575,15 @@ Framed frame_incident(const message::Request& request) noexcept {
                 }
                 report(core::log::Level::info,
                        "ev=activity stage=lore path=egg result=%s target=%u type=%d "
-                       "lane4=0x%08X record=%u",
+                       "lane4=0x%08X record=%u loot=%s item_hash=0x%08X item_index=%u",
                        egg.resolved ? state::lore::grant_outcome_name(egg.outcome) : "unresolved",
                        parsed.primaryTarget,
                        primary.typeCode,
                        primary.lane4,
-                       static_cast<unsigned>(egg.resolved ? egg.record : 0));
+                       static_cast<unsigned>(egg.resolved ? egg.record : 0),
+                       loot.granted ? "queued" : "failed",
+                       loot.definitionHash,
+                       loot.definitionIndex);
                 if (core::log::accepts(core::log::Channel::server, core::log::Level::info)) {
                     std::array<char, core::log::kLineCapacity> line{};
                     const int prefix = std::snprintf(line.data(), line.size(),
@@ -571,6 +657,50 @@ Framed frame_incident(const message::Request& request) noexcept {
                                found ? extra.lanes[7] : 0U);
                     }
 
+                    // Dust scans share one target identity. The physical scan position selects
+                    // the exact lore entry across the Derelict and Reckoning spaces.
+                    struct DustScan {
+                        std::array<float, 3> position;
+                        std::uint16_t record;
+                    };
+                    constexpr std::string_view kDerelictPackage = "pandora_freeroam";
+                    constexpr std::array<DustScan, 9> kDustScans{{
+                        {{{-40.861F, 147.410F, -2312.313F}}, 1571U}, // The Bone, Derelict
+                        {{{-681.393F, -859.831F, -8.590F}}, 1575U}, // The Declaration, first arena
+                        {{{2.871F, 236.277F, -2306.442F}}, 1569U},  // The Red Box, Derelict
+                        {{{7.362F, 237.020F, -2306.704F}}, 1572U},   // The Kell, Derelict
+                        {{{9.792F, 149.124F, -2319.657F}}, 1570U},   // The Stacks, Reckoning
+                        {{{-205.802F, -80.132F, -11.900F}}, 1574U}, // The Gate, Reckoning
+                        {{{-249.759F, 6.664F, -17.853F}}, 1573U},   // The Leviathan, Reckoning
+                        {{{-876.144F, -874.570F, 11.781F}}, 1576U}, // The Nine, Reckoning
+                        {{{-1323.416F, -534.063F, -298.928F}}, 1577U}, // The Witch, Reckoning
+                    }};
+                    constexpr float kDustScanRadiusSquared = 36.0F;
+                    if (player.present && packageName == kDerelictPackage) {
+                        for (const DustScan& scan : kDustScans) {
+                            const float dx = player.position[0] - scan.position[0];
+                            const float dy = player.position[1] - scan.position[1];
+                            const float dz = player.position[2] - scan.position[2];
+                            if (dx * dx + dy * dy + dz * dz > kDustScanRadiusSquared) {
+                                continue;
+                            }
+                            const auto outcome = state::lore::grant_record(scan.record);
+                            contextResolved = outcome != state::lore::GrantOutcome::recordNotFound
+                                              && outcome != state::lore::GrantOutcome::notAChapter;
+                            if (outcome == state::lore::GrantOutcome::granted) {
+                                bap::arm_account_resync_everywhere();
+                            }
+                            report(core::log::Level::info,
+                                   "ev=activity stage=lore path=position target=%u package=%.*s "
+                                   "record=%u result=%s",
+                                   parsed.primaryTarget,
+                                   static_cast<int>(packageName.size()), packageName.data(),
+                                   static_cast<unsigned>(scan.record),
+                                   state::lore::grant_outcome_name(outcome));
+                            break;
+                        }
+                    }
+
                     // Confessions vases have no per-object target. Their interaction positions are
                     // stable across captures, so each measured centre resolves its authored entry.
                     struct ConfessionsVase {

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

@@ -5,6 +5,7 @@
 #include <cstdio>
 
 #include "../../../../core/logging/log.h"
+#include "../../../../middleware/secure_channel/runtime.h"
 #include "../../../../state/account/account_state.h"
 #include "../../../../state/runtime/runtime.h"
 #include "../internal.h"
@@ -33,6 +34,61 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     }
 }
 
+/** Publishes and commits one world reward through the ordinary acquisition notification path. */
+[[nodiscard]] bool consume_world_item_acquisition(Session& session,
+                                                  Scratch& scratch,
+                                                  std::span<std::byte> response,
+                                                  std::size_t& written,
+                                                  bool& touchesScratch) noexcept {
+    if (!session.worldItemAcquisitionArmed) {
+        return false;
+    }
+    touchesScratch = true;
+    queuez::ItemAcquisition acquisition{};
+    const state::PendingItemAcquisition pending = session.pendingWorldItemAcquisition;
+    if (!queuez::stage_item_acquisition(session.queuez,
+                                        pending.accountSoid,
+                                        pending.characterSoid,
+                                        pending.acquiredInstanceSoid,
+                                        pending.profileChanged,
+                                        acquisition)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_acquisition result=fail reason=stage");
+        return false;
+    }
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    if (!push::append_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_acquisition result=fail reason=encode");
+        return false;
+    }
+    if (!state::commit_item_acquisition(session.pendingWorldItemAcquisition)) {
+        session.worldItemAcquisitionArmed = false;
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_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.worldItemAcquisitionArmed = false;
+    report_repush("world_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,
@@ -264,6 +320,12 @@ bool consume_deferred(Session& session,
     if (!session.authenticated) {
         return false;
     }
+    if (consume_world_item_acquisition(session, scratch, response, written, touchesScratch)) {
+        return true;
+    }
+    if (session.worldItemAcquisitionArmed) {
+        return false;
+    }
     if (consume_account_resync(session, scratch, response, written, touchesScratch)) {
         return true;
     }

+ 8 - 1
Sunrise/src/server/bap/internal.h

@@ -12,7 +12,7 @@
 #include "../../state/activity/bubble_authority/definition.h"
 #include "../../state/activity/definition.h"
 #include "../../state/build_data/scenarios/definition.h"
-#include "../../state/runtime/state.h"
+#include "../../state/runtime/runtime.h"
 #include "encrypted/queuez/definition.h"
 
 namespace sunrise::server::bap {
@@ -172,6 +172,9 @@ struct Session {
     bool accountMutationPublished{};
     /** True while another peer's account mutation still needs a full local refresh. */
     bool accountResyncArmed{};
+    /** Direct world reward waiting for its ordinary item-acquisition notification and commit. */
+    state::PendingItemAcquisition pendingWorldItemAcquisition{};
+    bool worldItemAcquisitionArmed{};
     /**
      * 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
@@ -192,6 +195,10 @@ struct Session {
  */
 void arm_account_resync_everywhere() noexcept;
 
+/** Queues one prepared world reward on an active Family-4 peer for normal acquisition feedback. */
+[[nodiscard]] bool
+arm_world_item_acquisition(state::PendingItemAcquisition acquisition) noexcept;
+
 namespace plaintext {
 
 /**

+ 18 - 30
Sunrise/src/state/record_claims/record_claims.cpp

@@ -700,18 +700,18 @@ constexpr std::int32_t kChapterGateFirstWritable = 1942;
 /** The last row whose chapter needs a gate. Beyond it every chapter is displayed by default. */
 constexpr std::uint16_t kChapterGateLastRow = 106;
 
-/** Publishes the per-chapter visibility gate of the Year 1 lore chapters. */
+/** Publishes the per-chapter visibility gate only for Year 1 lore chapters already collected. */
 std::size_t apply_chapter_visibility_gates(std::span<std::int32_t> objectiveValues) noexcept {
     const std::span<const objective_slot_table::RecordEntry> table{objective_slot_table::kRecords};
-    // The largest completion value any chapter in the block asks for. Read from the shipped table
-    // rather than hard-coded: the corrupted-egg records count to nine and Truth to Power's last
-    // chapter to eleven, and a gate written below a chapter's own requirement leaves it redacted.
-    std::int32_t ceiling = 1;
+    const std::lock_guard<std::mutex> guard(g_lock);
+    std::size_t written = 0;
     for (std::uint16_t row = 0; row <= kChapterGateLastRow; ++row) {
         build_data::records::Definition record{};
         if (!build_data::find_record_definition(row, record)
             || record.loreRow == build_data::records::kUnavailableLoreRow
-            || record.completionFlagIndex == build_data::records::kUnavailableFlagIndex) {
+            || record.completionFlagIndex == build_data::records::kUnavailableFlagIndex
+            || (!claimed_locked(record.completionFlagIndex)
+                && !claimable_locked(record.completionFlagIndex))) {
             continue;
         }
         const auto found = std::lower_bound(
@@ -719,33 +719,21 @@ std::size_t apply_chapter_visibility_gates(std::span<std::int32_t> objectiveValu
             [](const objective_slot_table::RecordEntry& entry, std::uint16_t flag) noexcept {
                 return entry.flagIndex < flag;
             });
-        if (found == table.end() || found->flagIndex != record.completionFlagIndex) {
+        if (found == table.end() || found->flagIndex != record.completionFlagIndex
+            || found->objectiveCount == 0) {
             continue;
         }
-        for (std::uint8_t i = 0; i < found->objectiveCount; ++i) {
-            const std::size_t at = static_cast<std::size_t>(found->firstObjective) + i;
-            if (at < objective_slot_table::kObjectives.size()) {
-                ceiling = std::max(ceiling, objective_slot_table::kObjectives[at].completionValue);
-            }
-        }
-    }
-
-    // The block is filled uniformly rather than addressed per chapter. Which slot inside it belongs
-    // to which chapter is NOT known: every measurement that located this block wrote one value
-    // across a contiguous span, and a uniform span satisfies a chapter wherever it sits, so none of
-    // them could distinguish the mapping. Addressing it as 1935 + record row was tried and is
-    // wrong -- The Dreaming City's "Riven" vanished while the slots either side of its supposed one
-    // stayed lit, and Truth to Power lost all eleven chapters that a uniform fill had shown.
-    // Filling to the ceiling reproduces the state measured good; resolving the mapping needs a
-    // per-slot sweep with distinct values and is worth doing before anything relies on it.
-    std::size_t written = 0;
-    const std::int32_t last = kChapterGateBase + static_cast<std::int32_t>(kChapterGateLastRow);
-    for (std::int32_t slot = kChapterGateFirstWritable; slot <= last; ++slot) {
-        if (static_cast<std::size_t>(slot) >= objectiveValues.size()) {
-            break;
+        const std::size_t objective = found->firstObjective;
+        const std::size_t gate = static_cast<std::size_t>(kChapterGateBase) + row;
+        if (row < static_cast<std::uint16_t>(kChapterGateFirstWritable - kChapterGateBase)
+            || objective >= objective_slot_table::kObjectives.size()
+            || gate >= objectiveValues.size()) {
+            continue;
         }
-        if (objectiveValues[static_cast<std::size_t>(slot)] < ceiling) {
-            objectiveValues[static_cast<std::size_t>(slot)] = ceiling;
+        const std::int32_t completion =
+            objective_slot_table::kObjectives[objective].completionValue;
+        if (objectiveValues[gate] < completion) {
+            objectiveValues[gate] = completion;
         }
         ++written;
     }

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

@@ -98,7 +98,7 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
 std::size_t apply_claimable_objectives(std::span<std::int32_t> objectiveValues) noexcept;
 
 /**
- * Publishes the per-chapter visibility gate of the Year 1 lore chapters.
+ * Publishes the per-chapter visibility gate of collected Year 1 lore chapters.
  *
  * A lore chapter is displayed only when a value slot of its own holds at least the chapter's
  * completion value; below that the client shows a redacted entry. The slot is the record's own row
@@ -106,7 +106,7 @@ std::size_t apply_claimable_objectives(std::span<std::int32_t> objectiveValues)
  * game, see kChapterGateBase -- and the test is a threshold, not an equality, which is why probing
  * the block with a flat 1 lit every chapter that completes at 1 and no other.
  *
- * Only the chapters below kChapterGateLastRow need this. Every later chapter is displayed by
+ * Only held chapters below kChapterGateLastRow are written. Every later chapter is displayed by
  * default, and the same arithmetic would put their slots inside the record-objective range, where
  * writing has previously redacted records wholesale.
  * @param objectiveValues Account value bank.