From 7167fb0d3a648dc68f951c48100066a8df13b983 Mon Sep 17 00:00:00 2001 From: saulo benigno Date: Fri, 25 Sep 2026 22:19:13 -0300 Subject: [PATCH] Add season progression controls and artifact power sync --- Sunrise/src/client/ui/player/player_panel.cpp | 111 ++++++++++++++++++ Sunrise/src/server/bap/bap_route.cpp | 48 ++++++++ Sunrise/src/server/bap/runtime.h | 20 ++++ Sunrise/src/state/runtime/runtime.h | 21 ++++ .../runtime/state_progression_runtime.cpp | 43 ++++++- 5 files changed, 237 insertions(+), 6 deletions(-) diff --git a/Sunrise/src/client/ui/player/player_panel.cpp b/Sunrise/src/client/ui/player/player_panel.cpp index 6b16e268d..a62b07f6c 100644 --- a/Sunrise/src/client/ui/player/player_panel.cpp +++ b/Sunrise/src/client/ui/player/player_panel.cpp @@ -2,12 +2,119 @@ #include "player_panel.h" +#include + +#include +#include +#include #include #include "../../../core/ui/components/toggle/ui_toggle_component.h" +#include "../../../server/bap/runtime.h" +#include "../../../state/runtime/runtime.h" #include "../../player/player_settings_store.h" namespace sunrise::client::ui::player { +namespace { + +/** Season values change rarely, so they are reread at this interval instead of every frame. */ +constexpr std::uint64_t kSeasonRefreshIntervalMs = 1'000; + +/** Season values last read from State. */ +struct SeasonValues { + std::int32_t experience{}; + std::uint16_t rank{}; + std::uint16_t powerBonus{}; + bool complete{}; + std::uint64_t readTick{}; + bool valid{}; +}; + +SeasonValues g_season{}; +/** Last grant or reset outcome, or null before the first one. */ +const char* g_seasonFeedback = nullptr; + +/** Rereads the Season values once the interval has passed or an action invalidated them. */ +void refresh_season() noexcept { + const std::uint64_t now = GetTickCount64(); + if (g_season.valid && now - g_season.readTick < kSeasonRefreshIntervalMs) { + return; + } + g_season.experience = state::seasonal_experience(); + g_season.rank = state::seasonal_rank(); + g_season.powerBonus = state::artifact_power_bonus(); + g_season.complete = state::seasonal_experience_to_advance(1) == 0; + g_season.readTick = now; + g_season.valid = true; +} + +/** Writes XP with a comma between thousands, the way the game prints XP. */ +void format_experience(std::int32_t experience, std::array& output) noexcept { + std::array digits{}; + const int count = std::snprintf(digits.data(), digits.size(), "%d", experience); + std::size_t written = 0; + for (int index = 0; index < count; ++index) { + if (index > 0 && digits[index - 1] != '-' && (count - index) % 3 == 0) { + output[written++] = ','; + } + output[written++] = digits[index]; + } + output[written] = '\0'; +} + +/** Keeps an action's outcome and rereads the values on the next frame. */ +void finish_season_action(bool done, const char* success, const char* failure) noexcept { + g_seasonFeedback = done ? success : failure; + g_season.valid = false; +} + +/** Grants whole ranks on a press. */ +void season_rank_button(const char* label, std::uint16_t ranks, const char* success) noexcept { + if (ImGui::Button(label)) { + finish_season_action(server::bap::grant_seasonal_ranks(ranks), success, "Grant failed."); + } +} + +/** Shows the account's Season progress, the grants and the reset. */ +void draw_season_progression() noexcept { + refresh_season(); + ImGui::TextUnformatted("Season Progression"); + ImGui::Separator(); + ImGui::TextWrapped("Increase the Season rank without losing current XP progress"); + ImGui::Spacing(); + ImGui::Text("Season Rank: %u", static_cast(g_season.rank)); + std::array experience{}; + format_experience(g_season.experience, experience); + ImGui::Text("Seasonal XP: %s", experience.data()); + ImGui::Text("Artifact Power Bonus: +%u", static_cast(g_season.powerBonus)); + ImGui::Spacing(); + ImGui::BeginDisabled(g_season.complete); + if (ImGui::Button("+50K XP##season_xp_50k")) { + finish_season_action( + server::bap::grant_seasonal_experience_capped(50'000), "XP granted.", "Grant failed."); + } + ImGui::SameLine(); + season_rank_button("+1 Rank##season_rank_1", 1, "Ranks granted."); + ImGui::SameLine(); + season_rank_button("+5 Ranks##season_rank_5", 5, "Ranks granted."); + ImGui::SameLine(); + // A successful Max Rank grant always stops exactly at the rank-100 threshold. + season_rank_button("Max Rank##season_rank_max", 100, "Rank 100 reached."); + ImGui::EndDisabled(); + ImGui::BeginDisabled(g_season.experience == 0); + if (ImGui::Button("Reset Season Progression##season_reset")) { + finish_season_action(server::bap::reset_seasonal_progression(), + "Season progression reset.", + "Reset failed."); + } + ImGui::EndDisabled(); + if (g_seasonFeedback != nullptr) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", g_seasonFeedback); + } +} + +} // namespace /** Draws the player module inside the active Core UI frame. */ void draw() noexcept { @@ -32,6 +139,10 @@ void draw() noexcept { if (changed) { (void)client::player::publish(settings); } + + ImGui::Spacing(); + ImGui::Spacing(); + draw_season_progression(); } } // namespace sunrise::client::ui::player diff --git a/Sunrise/src/server/bap/bap_route.cpp b/Sunrise/src/server/bap/bap_route.cpp index 9f30e617e..db7e78815 100644 --- a/Sunrise/src/server/bap/bap_route.cpp +++ b/Sunrise/src/server/bap/bap_route.cpp @@ -48,6 +48,18 @@ void publish_account_mutation(Session& origin) noexcept { arm_account_resync_elsewhere(origin); } +/** + * Owes every active peer one Family-5 snapshot after seasonal XP moved the artifact overrides. + * The Family-4 update that follows it makes the client rebuild the artifact view. + */ +void arm_artifact_refresh_everywhere() noexcept { + for (auto& peer : g_sessions) { + if (peer.id != 0 && peer.authenticated && peer.queuez.family4Active) { + peer.artifactRefreshArmed = true; + } + } +} + /** @param id Nonzero connection id. @return Matching open session, or null. */ [[nodiscard]] Session* session_for(std::uint32_t id) noexcept { if (id == 0 || id > g_sessions.size()) { @@ -370,9 +382,15 @@ void arm_acquisition_presentation_hold(Session& session) noexcept { * @return True when the XP was granted. */ bool arm_seasonal_experience_presentation(std::int32_t amount) noexcept { + const std::uint16_t bonusBefore = state::artifact_power_bonus(); if (amount <= 0 || !state::grant_seasonal_experience(amount)) { return false; } + arm_artifact_refresh_everywhere(); + // A new artifact bonus moves every character's Power; only an account resync redraws it. + if (state::artifact_power_bonus() != bonusBefore) { + arm_account_resync_everywhere(); + } for (auto& peer : g_sessions) { if (peer.id == 0 || !peer.authenticated || !peer.queuez.family4Active || peer.pendingSeasonalExperienceAmount @@ -387,6 +405,36 @@ bool arm_seasonal_experience_presentation(std::int32_t amount) noexcept { return true; } +/** Sizes the grant under the lock every seasonal XP grant holds, so none lands in between. */ +bool grant_seasonal_ranks(std::uint16_t ranks) noexcept { + const std::lock_guard lock(g_lock); + const std::int32_t amount = state::seasonal_experience_to_advance(ranks); + return amount > 0 && arm_seasonal_experience_presentation(amount); +} + +/** Caps the grant under the same lock as the rank grants, so no other XP lands in between. */ +bool grant_seasonal_experience_capped(std::int32_t amount) noexcept { + const std::lock_guard lock(g_lock); + const std::int32_t granted = state::seasonal_experience_within_pass(amount); + return granted > 0 && arm_seasonal_experience_presentation(granted); +} + +/** Zeroes seasonal XP, then republishes the account instead of presenting a gain. */ +bool reset_seasonal_progression() noexcept { + const std::lock_guard lock(g_lock); + if (!state::reset_seasonal_experience()) { + return false; + } + for (auto& peer : g_sessions) { + // A gain still queued would animate XP the reset just removed. + peer.pendingSeasonalExperienceAmount = 0; + peer.pendingSeasonalExperienceMutationSerial = 0; + } + arm_artifact_refresh_everywhere(); + arm_account_resync_everywhere(); + return true; +} + /** Finds one unambiguous registry identity in a committed connection-local roster map. */ const RosterDecodeEntry* find_roster_decode_entry(const RosterDecodeMap& map, std::uint64_t expectedBindingGeneration, diff --git a/Sunrise/src/server/bap/runtime.h b/Sunrise/src/server/bap/runtime.h index 8f8458906..890474788 100644 --- a/Sunrise/src/server/bap/runtime.h +++ b/Sunrise/src/server/bap/runtime.h @@ -35,6 +35,26 @@ void unregister_client_investment_slice_consumer() noexcept; /** Asks for one more investment refresh slice. Does nothing while no Client is registered. */ void request_investment_slice() noexcept; +/** + * Grants the seasonal XP for whole ranks and queues its HUD notification like any earned XP. + * @param ranks Ranks to advance; the grant stops at the Season pass rank-100 threshold. + * @return False when the pass is already complete or the grant was refused. + */ +[[nodiscard]] bool grant_seasonal_ranks(std::uint16_t ranks) noexcept; + +/** + * Grants seasonal XP through the same notification path, cut at the rank-100 threshold. + * @param amount XP to grant; only the part below the threshold is granted. + * @return False for a non-positive amount, a complete pass, or a refused grant. + */ +[[nodiscard]] bool grant_seasonal_experience_capped(std::int32_t amount) noexcept; + +/** + * Returns seasonal XP to zero and resyncs every peer without an XP notification. + * @return False when the State reset was refused; nothing changed then. + */ +[[nodiscard]] bool reset_seasonal_progression() noexcept; + /** Read-only eligibility state from one exact authenticated ActivityClient link. */ struct ActivityLinkView final { std::size_t matchingLinks{}; diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 76525e31d..e7a2645fd 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -785,6 +785,27 @@ inline constexpr std::uint16_t kArtifactUnlockProgressionIndex = 39; */ [[nodiscard]] bool grant_seasonal_experience(std::int32_t amount) noexcept; +/** + * Replaces seasonal XP with zero and republishes every value derived from it, atomically. + * Artifact ownership, Season pass claims and record flags are left as they are. + * @return False when any write fails; nothing is kept then. + */ +[[nodiscard]] bool reset_seasonal_experience() noexcept; + +/** + * XP that moves the published total whole ranks forward, keeping its progress inside the rank. + * @param ranks Ranks to advance. + * @return XP to grant, stopping at the rank-100 threshold; zero once the pass is complete. + */ +[[nodiscard]] std::int32_t seasonal_experience_to_advance(std::uint16_t ranks) noexcept; + +/** + * Part of an XP amount the Season pass still has room for below its rank-100 threshold. + * @param amount XP the caller wants to grant. + * @return XP to grant; zero for a non-positive amount or once the pass is complete. + */ +[[nodiscard]] std::int32_t seasonal_experience_within_pass(std::int32_t amount) noexcept; + /** @param rewardIndex Native reward-array index. @return True when the row is claimed. */ [[nodiscard]] bool season_pass_reward_claimed(std::uint16_t rewardIndex) noexcept; diff --git a/Sunrise/src/state/runtime/state_progression_runtime.cpp b/Sunrise/src/state/runtime/state_progression_runtime.cpp index ffe609608..c8fe45025 100644 --- a/Sunrise/src/state/runtime/state_progression_runtime.cpp +++ b/Sunrise/src/state/runtime/state_progression_runtime.cpp @@ -234,10 +234,11 @@ bool publish_artifact_character_banks(CharacterArtifactWrite& write) noexcept { } strip_artifact_flags_locked(family, rows, count); const std::uint16_t used = points_used(mask); + // Owned mods keep the points they cost, so XP below them (after a reset) never goes negative. + const std::uint16_t earned = (std::max)(artifact_points_earned_for(experience), used); if (!upsert_value(family, kArtifactPowerBonusSlot, artifact_power_bonus_for(experience)) || !upsert_value(family, kArtifactPointsUsedSlot, used) - || !upsert_value( - family, kArtifactPointsEarnedSlot, artifact_points_earned_for(experience))) { + || !upsert_value(family, kArtifactPointsEarnedSlot, earned)) { return false; } CharacterArtifactWrite write{&rows, count, mask, used}; @@ -250,10 +251,10 @@ bool publish_experience_lanes(std::int32_t experience) noexcept { && unlocks::set_account_progression(kArtifactUnlockProgressionIndex, experience) && unlocks::set_account_progression(pass::kProgressionDefinitionIndex, (std::min)(experience, kMaximumPassExperience)) - && unlocks::set_account_progression(pass::kHudProgressionDefinitionIndex, - experience < kMaximumPassExperience - ? experience % kExperiencePerRank - : experience - kMaximumPassExperience); + // The HUD bar repeats past rank 100 only; earlier XP there shows a rank-100 banner. + && unlocks::set_account_progression( + pass::kHudProgressionDefinitionIndex, + experience < kMaximumPassExperience ? 0 : experience - kMaximumPassExperience); } } // namespace @@ -359,6 +360,36 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept { return saved; } +/** Publishes zero XP the way a grant publishes its total, keeping the owned artifact mods. */ +bool reset_seasonal_experience() noexcept { + investment::store::Transaction transaction; + SaleRows rows{}; + std::size_t count = 0; + Family5State family; + if (!transaction.ready() || !sale_rows(rows, count) + || !investment::store::read_family5(family)) { + return false; + } + const std::uint32_t mask = artifact_mask(rows, count); + return publish_experience_lanes(0) && publish_artifact_locked(family, mask, 0) + && investment::store::write_family5(family) && transaction.commit(); +} + +/** The pass threshold is the only cap; below it the whole amount fits. */ +std::int32_t seasonal_experience_within_pass(std::int32_t amount) noexcept { + // A negative total is invalid State; count from zero instead of below it. + const std::int32_t room = kMaximumPassExperience - (std::max)(seasonal_experience(), 0); + return amount > 0 && room > 0 ? (std::min)(amount, room) : 0; +} + +/** Whole ranks keep the progress inside the current rank until the pass threshold cuts it. */ +std::int32_t seasonal_experience_to_advance(std::uint16_t ranks) noexcept { + // No request needs more than the whole pass, which keeps the product inside the XP type. + const std::int64_t wanted = (std::min)(static_cast(ranks) * kExperiencePerRank, + static_cast(kMaximumPassExperience)); + return seasonal_experience_within_pass(static_cast(wanted)); +} + /** @return True when this Season pass reward row is already claimed. */ bool season_pass_reward_claimed(std::uint16_t rewardIndex) noexcept { build_data::season_pass::Reward reward{};