Преглед изворни кода

Count claimed records into the value slot a presentation node names

Claiming the chapters of a lore book never moved the book's progress, because the bar is not derived
by the client from its children. The node names a value slot and shows what that slot holds, and
nothing was writing it.

Add a nodes domain carrying, per node, the value slot its expression names and the records it owns
at row +136, both resolved at extraction. Count the claimed children against the claim store and
write the count into the slot, in the encoder beside where the claim score is written.

Measured: claiming chapters of The Pigeon and the Phoenix raises node 827 from four to five, and no
other node moves. The children and the count are right; whether the bar reads this particular slot
is not yet confirmed.

Two known limits. Only twenty-four nodes drive anything, because the extraction takes the first
value read and ignores nodes whose expression reads only a flag, which several books do. And the
node display entry at +12 is not a name: node 827 resolves to The Liar there while being The Pigeon
and the Phoenix, so it points at a title record rather than the node.
Millie пре 2 недеља
родитељ
комит
fb26833f97

+ 21 - 0
Sunrise/src/client/content/items/packages/internal.h

@@ -89,6 +89,10 @@ struct Storage {
     std::array<state::build_data::records::Definition,
                state::build_data::records::kDefinitionCapacity>
         recordRows{};
+    /** Node rows held until the value slot and owned records are resolved. */
+    std::array<state::build_data::nodes::Definition,
+               state::build_data::nodes::kDefinitionCapacity>
+        nodeRows{};
     std::array<state::build_data::collectibles::Definition,
                state::build_data::collectibles::kDefinitionCapacity>
         collectibleRows{};
@@ -262,6 +266,23 @@ read_investment_constants(const reader::Source& source,
  * @param count Receives the number of rows read.
  * @return True when both tables read and every row fits.
  */
+/**
+ * Reads the presentation node table and resolves each node's value slot and owned records.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the tables.
+ * @param output Row storage in native node order.
+ * @param count Receives the number of rows read.
+ * @return True when both tables read and every row fits.
+ */
+[[nodiscard]] bool build_nodes(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               std::span<const std::byte> root,
+                               std::vector<std::byte>& blob,
+                               std::span<state::build_data::nodes::Definition> output,
+                               std::size_t& count) noexcept;
+
 [[nodiscard]] bool build_records(const reader::Source& source,
                                  reader::Scratch& scratch,
                                  std::span<const std::byte> root,

+ 12 - 0
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -155,6 +155,18 @@ bool build() noexcept {
                         std::span(storage.progressionRows).first(progressionCount));
                 }
             }
+            if (!state::build_data::node_definitions_ready()) {
+                std::size_t nodeCount = 0;
+                if (build_nodes(source,
+                                storage.scratch,
+                                std::span<const std::byte>{storage.root},
+                                storage.child,
+                                storage.nodeRows,
+                                nodeCount)) {
+                    (void)state::build_data::publish_node_definitions(
+                        std::span(storage.nodeRows).first(nodeCount));
+                }
+            }
             if (!state::build_data::record_definitions_ready()) {
                 std::size_t recordCount = 0;
                 if (build_records(source,

+ 181 - 0
Sunrise/src/client/content/items/packages/package_node_build.cpp

@@ -0,0 +1,181 @@
+#include <array>
+#include <cstdio>
+#include <cstring>
+#include <unordered_map>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../state/build_data/runtime.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Reports where the node pass stopped, so a silent miss cannot look like a stuck progress bar. */
+void report(const char* stage, unsigned long long detail) noexcept {
+    std::array<char, 128> line{};
+    const int count = std::snprintf(
+        line.data(), line.size(), "ev=pkg stage=nodes result=%s detail=%llu", stage, detail);
+    if (count > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+/** Reads a value slot out of one node expression, or reports that it names none. */
+[[nodiscard]] bool expression_value_slot(std::span<const std::byte> table,
+                                         std::size_t rowAt,
+                                         std::size_t field,
+                                         std::int16_t& slot) noexcept {
+    std::int64_t count = 0;
+    std::int64_t relative = 0;
+    std::memcpy(&count, table.data() + rowAt + field, sizeof count);
+    std::memcpy(&relative, table.data() + rowAt + field + 8, sizeof relative);
+    if (count < 1 || count > tables::kNodeExpressionCapacity) {
+        return false;
+    }
+    const std::size_t pointerAt = rowAt + field + 8;
+    const std::int64_t target = static_cast<std::int64_t>(pointerAt) + relative
+                                + static_cast<std::int64_t>(tables::kHeaderSkip);
+    if (target < 0
+        || static_cast<std::size_t>(target)
+                   + static_cast<std::size_t>(count) * tables::kUnlockInstructionStride
+               > table.size()) {
+        return false;
+    }
+    const auto base = static_cast<std::size_t>(target);
+    for (std::int64_t index = 0; index < count; ++index) {
+        std::uint32_t opcode = 0;
+        std::uint32_t operand = 0;
+        const std::size_t at =
+            base + static_cast<std::size_t>(index) * tables::kUnlockInstructionStride;
+        std::memcpy(&opcode, table.data() + at, sizeof opcode);
+        std::memcpy(&operand, table.data() + at + 4, sizeof operand);
+        if (opcode > tables::kUnlockOpcodeCeiling) {
+            return false;
+        }
+        // Opcode ten reads a value, which is the slot a node's progress bar shows.
+        if (opcode == tables::kUnlockReadValueOpcode
+            && operand <= static_cast<std::uint32_t>(INT16_MAX)) {
+            slot = static_cast<std::int16_t>(operand);
+            return true;
+        }
+    }
+    return false;
+}
+
+} // namespace
+
+/**
+ * Reads the presentation node table and resolves each node's value slot and owned records.
+ *
+ * A node's progress bar shows a value slot named by its own expression, and the records it owns sit
+ * at row `+136` as a row and a gate. Both are read here so a claim never has to walk the node table.
+ */
+bool build_nodes(const reader::Source& source,
+                 reader::Scratch& scratch,
+                 std::span<const std::byte> root,
+                 std::vector<std::byte>& blob,
+                 std::span<state::build_data::nodes::Definition> output,
+                 std::size_t& count) noexcept {
+    namespace domain = state::build_data::nodes;
+    count = 0;
+
+    std::uint32_t mapTag = 0;
+    tables::Array mapRows{};
+    if (!tables::slot_tag(root, tables::kUnlockValueMapTableSlot, mapTag) || mapTag == 0
+        || tables::package_of(mapTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, mapTag, blob)
+        || !tables::find_array_at(std::span<const std::byte>{blob},
+                                  tables::kAccountValueMapDescriptor,
+                                  mapRows)
+        || mapRows.count == 0
+        || mapRows.dataOffset
+                   + static_cast<std::size_t>(mapRows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        report("value_map_fail", mapTag);
+        return false;
+    }
+    std::unordered_map<std::int16_t, std::uint16_t> indexBySlot{};
+    for (std::uint64_t row = 0; row < mapRows.count && row <= domain::kUnavailableValueIndex;
+         ++row) {
+        const std::size_t at =
+            mapRows.dataOffset + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride;
+        std::int16_t slot = 0;
+        std::memcpy(&slot, blob.data() + at + tables::kUnlockMapDestinationSlotOffset, sizeof slot);
+        indexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+    }
+
+    std::uint32_t tableTag = 0;
+    tables::Array rows{};
+    if (!tables::slot_tag(root, tables::kPresentationNodeTableSlot, tableTag) || tableTag == 0
+        || tables::package_of(tableTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kTableArrayDescriptor, rows)
+        || rows.count == 0 || rows.count > output.size()
+        || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kNodeRowStride
+               > blob.size()) {
+        report("node_table_fail", tableTag);
+        return false;
+    }
+
+    const std::span<const std::byte> table{blob};
+    std::size_t driving = 0;
+    for (std::uint64_t row = 0; row < rows.count; ++row) {
+        const std::size_t at =
+            rows.dataOffset + static_cast<std::size_t>(row) * tables::kNodeRowStride;
+        domain::Definition& definition = output[static_cast<std::size_t>(row)];
+        definition = {};
+        definition.definitionIndex = static_cast<std::uint16_t>(row);
+
+        // The expression sits at one of two fields, and only one of them holds it on any node.
+        std::int16_t slot = 0;
+        const bool named =
+            expression_value_slot(table, at, tables::kNodeExpressionFieldPrimary, slot)
+            || expression_value_slot(table, at, tables::kNodeExpressionFieldAlternate, slot);
+        if (named) {
+            const auto found = indexBySlot.find(slot);
+            if (found != indexBySlot.end()) {
+                definition.valueIndex = found->second;
+            }
+        }
+
+        // Records the node owns, four bytes each as a row and a gate.
+        std::int64_t childCount = 0;
+        std::int64_t childRelative = 0;
+        std::memcpy(&childCount, table.data() + at + tables::kNodeChildRecordField,
+                    sizeof childCount);
+        std::memcpy(&childRelative, table.data() + at + tables::kNodeChildRecordField + 8,
+                    sizeof childRelative);
+        if (childCount >= 1 && childCount <= static_cast<std::int64_t>(domain::kChildCapacity)) {
+            const std::size_t pointerAt = at + tables::kNodeChildRecordField + 8;
+            const std::int64_t target = static_cast<std::int64_t>(pointerAt) + childRelative
+                                        + static_cast<std::int64_t>(tables::kHeaderSkip);
+            if (target >= 0
+                && static_cast<std::size_t>(target)
+                           + static_cast<std::size_t>(childCount) * tables::kNodeChildRecordStride
+                       <= table.size()) {
+                const auto base = static_cast<std::size_t>(target);
+                for (std::int64_t index = 0; index < childCount; ++index) {
+                    std::uint16_t childRow = 0;
+                    std::memcpy(&childRow,
+                                table.data() + base
+                                    + static_cast<std::size_t>(index)
+                                          * tables::kNodeChildRecordStride,
+                                sizeof childRow);
+                    definition.children[static_cast<std::size_t>(definition.childCount++)] =
+                        childRow;
+                }
+            }
+        }
+        if (definition.childCount != 0 && definition.valueIndex != domain::kUnavailableValueIndex) {
+            ++driving;
+        }
+        ++count;
+    }
+    report("ok", static_cast<unsigned long long>(driving));
+    return count != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 27 - 0
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -97,6 +97,33 @@ inline constexpr std::size_t kRecordCompletionFlagOffset = 100;
 /** Points the record is worth. Zero for lore and for the interval records that score per step. */
 inline constexpr std::size_t kRecordScoreOffset = 92;
 
+/** Investment root slot of the presentation node table. */
+inline constexpr std::size_t kPresentationNodeTableSlot = 63;
+/** One node row. Measured from the spacing of four known node hashes, not divided out of the blob. */
+inline constexpr std::size_t kNodeRowStride = 168;
+/** A node's expression sits at one of these two fields, never both. */
+inline constexpr std::size_t kNodeExpressionFieldPrimary = 64;
+inline constexpr std::size_t kNodeExpressionFieldAlternate = 48;
+/** Records a node owns, four bytes each as a row then a gate. */
+inline constexpr std::size_t kNodeChildRecordField = 136;
+inline constexpr std::size_t kNodeChildRecordStride = 4;
+/** No node expression seen is longer than this. */
+inline constexpr std::int64_t kNodeExpressionCapacity = 32;
+
+/** One unlock expression instruction: an opcode then its operand. */
+inline constexpr std::size_t kUnlockInstructionStride = 8;
+/** Opcodes run to fifteen; anything wider means the field is not an expression. */
+inline constexpr std::uint32_t kUnlockOpcodeCeiling = 20;
+/** The opcode that reads a value slot. */
+inline constexpr std::uint32_t kUnlockReadValueOpcode = 10;
+/** Array payloads begin after a sixteen byte header. */
+inline constexpr std::size_t kHeaderSkip = 16;
+
+/** Investment root slot of the four unlock value mapping tables. */
+inline constexpr std::size_t kUnlockValueMapTableSlot = 113;
+/** Array descriptor of the account object's value mapping table. */
+inline constexpr std::size_t kAccountValueMapDescriptor = 8;
+
 /** Investment root slot of the five unlock flag mapping tables. */
 inline constexpr std::size_t kUnlockFlagMapTableSlot = 111;
 /** Array descriptor of the account object's flag mapping table, the bank flag runs author. */

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

@@ -106,6 +106,9 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
         auto& score = object.objectiveValues[state::build_data::records::kTriumphScoreValueIndex];
         score += static_cast<std::int32_t>(state::record_claims::total_score());
     }
+    // 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);
     for (layout::CharacterUnlockBlock& block : object.characterUnlocks) {
         block.flags = unlocks.characterFlags;
     }

+ 37 - 0
Sunrise/src/state/build_data/nodes/definition.h

@@ -0,0 +1,37 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::state::build_data::nodes {
+
+/** The shipped build declares 924 presentation nodes. The domain leaves room above that. */
+inline constexpr std::size_t kDefinitionCapacity = 1024;
+
+/** No shipped node owns more records than this; the widest seen is a lore book at fifteen. */
+inline constexpr std::size_t kChildCapacity = 64;
+
+/** A node whose expression names no addressable value slot carries this instead of an index. */
+inline constexpr std::uint16_t kUnavailableValueIndex = 0xFFFFU;
+
+/**
+ * One presentation node reduced to what a progress bar needs.
+ *
+ * A node's bar is not derived by the client from its children. The node carries an expression that
+ * reads a value slot, and the bar shows whatever that slot holds, so a server that wants the bar to
+ * move has to count the claimed children itself and write the count. The slot is resolved to its
+ * mapping-table row here, at extraction, exactly as a record's completion flag is.
+ */
+struct Definition {
+    /** Native node row. */
+    std::uint16_t definitionIndex{};
+    /** Account value bank mapping row, or kUnavailableValueIndex when no slot is addressable. */
+    std::uint16_t valueIndex{kUnavailableValueIndex};
+    /** Records this node owns, held at node row `+136`. */
+    std::uint8_t childCount{};
+    /** Native record rows of the owned records. */
+    std::array<std::uint16_t, kChildCapacity> children{};
+};
+
+} // namespace sunrise::state::build_data::nodes

+ 19 - 0
Sunrise/src/state/build_data/nodes/node_build_data_runtime.cpp

@@ -0,0 +1,19 @@
+#include "../runtime.h"
+#include "../runtime/persistence/publication_transaction.h"
+#include "node_catalog.h"
+
+namespace sunrise::state::build_data {
+
+/** @return True when the whole native node table is published. */
+bool node_definitions_ready() noexcept {
+    return nodes::count() != 0;
+}
+
+/** Publishes one complete dense node-to-value-slot table. */
+bool publish_node_definitions(std::span<const nodes::Definition> definitions) noexcept {
+    runtime::persistence::Transaction transaction;
+    return transaction.active() && nodes::valid(definitions)
+           && transaction.finish(nodes::replace(definitions), nodes::clear);
+}
+
+} // namespace sunrise::state::build_data

+ 68 - 0
Sunrise/src/state/build_data/nodes/node_catalog.cpp

@@ -0,0 +1,68 @@
+#include "node_catalog.h"
+
+#include "../table.h"
+
+namespace sunrise::state::build_data::nodes {
+namespace {
+
+Lock g_lock;
+Table<Definition, kDefinitionCapacity> g_definitions;
+
+} // namespace
+
+/** Clears every generated node definition under the catalog lock. */
+void clear() noexcept {
+    const Lock::Exclusive guard(g_lock);
+    g_definitions.clear();
+}
+
+/** Checks that the definitions are dense and in native index order. */
+bool valid(std::span<const Definition> definitions) noexcept {
+    if (definitions.empty() || definitions.size() > kDefinitionCapacity) {
+        return false;
+    }
+    for (std::size_t row = 0; row < definitions.size(); ++row) {
+        if (definitions[row].definitionIndex != row
+            || definitions[row].childCount > kChildCapacity) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Replaces the generated node definitions in one step. */
+bool replace(std::span<const Definition> definitions) noexcept {
+    if (!valid(definitions)) {
+        return false;
+    }
+    const Lock::Exclusive guard(g_lock);
+    return g_definitions.replace(definitions);
+}
+
+/** Runs one callable over every node that drives a value slot. */
+void for_each_driving(void* context,
+                      void (*visit)(void* context, const Definition& definition)) noexcept {
+    if (visit == nullptr) {
+        return;
+    }
+    const Lock::Shared guard(g_lock);
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.childCount != 0 && definition.valueIndex != kUnavailableValueIndex) {
+            visit(context, definition);
+        }
+    }
+}
+
+/** Copies every row in native node order. */
+bool snapshot(std::span<Definition> output, std::size_t& count) noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.snapshot(output, count);
+}
+
+/** @return Number of generated node definitions, read under the lock. */
+std::size_t count() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.count();
+}
+
+} // namespace sunrise::state::build_data::nodes

+ 46 - 0
Sunrise/src/state/build_data/nodes/node_catalog.h

@@ -0,0 +1,46 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+#include "definition.h"
+
+namespace sunrise::state::build_data::nodes {
+
+/** Clears every generated node definition. */
+void clear() noexcept;
+
+/**
+ * Checks that the definitions are dense and in native index order.
+ * @param definitions Candidate rows.
+ * @return True when the rows fit storage, index n sits at position n, and no child count overflows.
+ */
+[[nodiscard]] bool valid(std::span<const Definition> definitions) noexcept;
+
+/**
+ * Replaces the generated node definitions in one step.
+ * @param definitions Complete dense rows in native node order.
+ * @return True when the rows pass the checks and fit fixed State storage.
+ */
+[[nodiscard]] bool replace(std::span<const Definition> definitions) noexcept;
+
+/**
+ * Runs one callable over every node that drives a value slot.
+ * Held under the shared lock, so the callable must not re-enter this domain.
+ * @param visit Receives each node owning at least one record and an addressable value slot.
+ */
+void for_each_driving(void* context,
+                      void (*visit)(void* context, const Definition& definition)) noexcept;
+
+/**
+ * Copies every row in native node order.
+ * @param output Caller-owned fixed row storage.
+ * @param count Receives the copied row count, or zero when output is too small.
+ * @return True when output can hold every row.
+ */
+[[nodiscard]] bool snapshot(std::span<Definition> output, std::size_t& count) noexcept;
+
+/** @return Number of generated node definitions, read under the lock. */
+[[nodiscard]] std::size_t count() noexcept;
+
+} // namespace sunrise::state::build_data::nodes

+ 12 - 0
Sunrise/src/state/build_data/runtime.h

@@ -17,6 +17,7 @@
 #include "items/socket_plugs/definition.h"
 #include "material_requirements/material_requirement_catalog.h"
 #include "progressions/definition.h"
+#include "nodes/definition.h"
 #include "records/definition.h"
 #include "scenarios/definition.h"
 #include "socket_entry_buckets/definition.h"
@@ -208,6 +209,17 @@ publish_socket_plug_rules(std::span<const items::socket_plugs::Rule> rules,
 [[nodiscard]] bool is_consumed_on_apply(std::uint16_t itemDefinitionIndex,
                                         std::uint8_t bucketId) noexcept;
 
+/** @return True when the whole presentation node table is in State. */
+[[nodiscard]] bool node_definitions_ready() noexcept;
+
+/**
+ * Publishes the whole presentation node table in one step.
+ * @param definitions Complete dense rows in native node order.
+ * @return True when the rows pass the domain checks and fit fixed State storage.
+ */
+[[nodiscard]] bool
+publish_node_definitions(std::span<const nodes::Definition> definitions) noexcept;
+
 /** @return True when the whole record definition table is in State. */
 [[nodiscard]] bool record_definitions_ready() noexcept;
 

+ 65 - 0
Sunrise/src/state/record_claims/record_claims.cpp

@@ -11,6 +11,9 @@
 
 #include "../../core/filesystem/path.h"
 #include "../../core/logging/log.h"
+#include "../build_data/nodes/definition.h"
+#include "../build_data/nodes/node_catalog.h"
+#include "../build_data/runtime.h"
 #include "../unlocks/definition.h"
 
 namespace sunrise::state::record_claims {
@@ -233,6 +236,68 @@ std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept {
     return changed;
 }
 
+namespace {
+
+/** Carries the bank and a tally through the node walk, which takes a plain function pointer. */
+struct NodeProgress {
+    std::span<std::int32_t> values;
+    std::size_t written;
+};
+
+/** True when this account flag bank row is held. The caller owns the claim lock. */
+[[nodiscard]] bool claimed_locked(std::uint16_t flagIndex) noexcept {
+    if (static_cast<std::size_t>(flagIndex) >= kIndexCapacity) {
+        return false;
+    }
+    const std::size_t word = static_cast<std::size_t>(flagIndex) / kWordBits;
+    const std::uint64_t bit = std::uint64_t{1} << (static_cast<std::size_t>(flagIndex) % kWordBits);
+    return (g_claimed[word] & bit) != 0;
+}
+
+} // namespace
+
+/** Writes each node's claimed-child count into the value slot its bar reads. */
+std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcept {
+    NodeProgress progress{objectiveValues, 0};
+    // The claim lock is taken first and the node lock inside the walk. Nothing takes them the other
+    // way round, so the order cannot close a cycle.
+    const std::lock_guard<std::mutex> guard(g_lock);
+    build_data::nodes::for_each_driving(
+        &progress, [](void* context, const build_data::nodes::Definition& node) noexcept {
+            auto* state = static_cast<NodeProgress*>(context);
+            std::int32_t claimed = 0;
+            for (std::size_t child = 0; child < node.childCount; ++child) {
+                build_data::records::Definition record{};
+                if (!build_data::find_record_definition(node.children[child], record)
+                    || record.completionFlagIndex
+                           == build_data::records::kUnavailableFlagIndex) {
+                    continue;
+                }
+                if (claimed_locked(record.completionFlagIndex)) {
+                    ++claimed;
+                }
+            }
+            if (static_cast<std::size_t>(node.valueIndex) < state->values.size()) {
+                state->values[node.valueIndex] = claimed;
+                ++state->written;
+                // TEMPORARY: name every node driven, so a bar that does not move can be told from
+                // a node that was never counted.
+                std::array<char, 160> line{};
+                const int written = std::snprintf(
+                    line.data(), line.size(),
+                    "ev=nodeprog node=%u value_index=%u children=%u claimed=%d",
+                    static_cast<unsigned>(node.definitionIndex),
+                    static_cast<unsigned>(node.valueIndex),
+                    static_cast<unsigned>(node.childCount), claimed);
+                if (written > 0) {
+                    core::log::write(core::log::Channel::state, core::log::Level::info,
+                                     {line.data(), static_cast<std::size_t>(written)});
+                }
+            }
+        });
+    return progress.written;
+}
+
 /** @return Total score of every held claim. */
 std::uint32_t total_score() noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);

+ 10 - 0
Sunrise/src/state/record_claims/record_claims.h

@@ -44,6 +44,16 @@ void clear() noexcept;
  */
 std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept;
 
+/**
+ * Writes each presentation node's claimed-child count into the value slot its bar reads.
+ *
+ * A node's progress bar is not derived by the client from its children: the node names a value slot
+ * and shows whatever it holds. Counting here is what makes claiming a chapter move its book.
+ * @param objectiveValues Account value bank, already filled from the authored policy.
+ * @return Number of nodes whose slot was written.
+ */
+std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcept;
+
 /** @return Total score of every held claim. */
 [[nodiscard]] std::uint32_t total_score() noexcept;