Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions Sunrise/src/client/ui/player/player_panel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,119 @@

#include "player_panel.h"

#include <Windows.h>

#include <array>
#include <cstdint>
#include <cstdio>
#include <imgui.h>

#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<char, 16>& output) noexcept {
std::array<char, 12> 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<unsigned>(g_season.rank));
std::array<char, 16> experience{};
format_experience(g_season.experience, experience);
ImGui::Text("Seasonal XP: %s", experience.data());
ImGui::Text("Artifact Power Bonus: +%u", static_cast<unsigned>(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 {
Expand All @@ -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
48 changes: 48 additions & 0 deletions Sunrise/src/server/bap/bap_route.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions Sunrise/src/server/bap/runtime.h
Original file line number Diff line number Diff line change
Expand Up @@ -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{};
Expand Down
21 changes: 21 additions & 0 deletions Sunrise/src/state/runtime/runtime.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
43 changes: 37 additions & 6 deletions Sunrise/src/state/runtime/state_progression_runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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
Expand Down Expand Up @@ -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<std::int64_t>(ranks) * kExperiencePerRank,
static_cast<std::int64_t>(kMaximumPassExperience));
return seasonal_experience_within_pass(static_cast<std::int32_t>(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{};
Expand Down
Loading