fix: resolve all 8 open bug tickets (#242–#249) (#250)
CI / Lint, Build & Test (push) Successful in 2m15s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m13s

## Summary

- **#242** — Crystals in the resource bar now use `formatNumber` to respect the player's notation setting (suffix/scientific/engineering)
- **#243** — Companion unlock progress includes current-run gold (`totalGoldEarned`) on both client and server, so companions unlock at the correct threshold
- **#244** — Empty green reward bubbles no longer render for quest crystal rewards with a zero amount
- **#245/#248** — Auto-save skips when `isAutoPrestigingReference.current` is true, preventing it from racing with an in-flight prestige and breaking the optimistic lock
- **#246** — Generated and uploaded CDN images for `crystal_pulse`, `crystal_surge`, and `crystal_tempest` upgrades
- **#247** — `validateAndSanitize` merges daily challenge progress by taking the max of client vs. server progress per challenge, so stale auto-saves can no longer roll back server-side completions
- **#249** — Cached save signature is cleared after `buyPrestigeUpgrade` succeeds, preventing a stale-signature mismatch on the next auto-save

## Test plan

- [ ] Lint passes (`pnpm lint`)
- [ ] Build passes (`pnpm build`)
- [ ] Tests pass with 100% coverage (`pnpm test`)
- [ ] Crystals display in resource bar respects notation setting
- [ ] No empty reward bubbles on quests that don't award crystals
- [ ] Companion progress bar shows correct value including current-run gold
- [ ] Auto-prestige no longer causes save errors
- [ ] Crafting a recipe updates daily challenge progress persistently (not rolled back by next auto-save)
- [ ] Buying a prestige upgrade does not cause a signature mismatch error on next save
- [ ] Crystal upgrade images display correctly in-game

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #250
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #250.
This commit is contained in:
2026-04-13 09:50:20 -07:00
committed by Naomi Carrigan
parent e341db56af
commit 9bb1d01d2b
5 changed files with 58 additions and 6 deletions
+42 -1
View File
@@ -681,6 +681,45 @@ const validateAndSanitize = (
storySpread = { story: previous.story };
}
/*
* Merge daily challenge progress: take the maximum progress for each
* challenge so a stale auto-save arriving after a craft/boss/etc. update
* cannot silently roll back server-side challenge completions.
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 35 -- @preserve */
let dailyChallengesSpread: object = {};
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
if (incoming.dailyChallenges !== undefined && previous.dailyChallenges !== undefined) {
const previousChallengeMap = new Map(
previous.dailyChallenges.challenges.map((challenge) => {
return [ challenge.id, challenge ];
}),
);
// eslint-disable-next-line stylistic/max-len -- Long chain; splitting would reduce readability
const mergedChallenges = incoming.dailyChallenges.challenges.map((challenge) => {
const serverChallenge = previousChallengeMap.get(challenge.id);
if (serverChallenge === undefined) {
return challenge;
}
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
const bestProgress = Math.max(challenge.progress, serverChallenge.progress);
return {
...challenge,
completed: bestProgress >= challenge.target,
progress: bestProgress,
};
});
dailyChallengesSpread = {
dailyChallenges: {
...incoming.dailyChallenges,
challenges: mergedChallenges,
},
};
} else if (previous.dailyChallenges !== undefined) {
dailyChallengesSpread = { dailyChallenges: previous.dailyChallenges };
}
return {
...incoming,
achievements,
@@ -693,6 +732,7 @@ const validateAndSanitize = (
...apotheosisSpread,
...explorationSpread,
...storySpread,
...dailyChallengesSpread,
};
};
@@ -1024,7 +1064,8 @@ gameRouter.post("/save", async(context) => {
const companionUnlocks = computeUnlockedCompanionIds({
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
// eslint-disable-next-line stylistic/max-len -- Long property; splitting would reduce readability
lifetimeGoldEarned: (playerRecord?.lifetimeGoldEarned ?? 0) + stateToSave.player.totalGoldEarned,
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
prestigeCount: stateToSave.prestige.count,
transcendenceCount: stateToSave.transcendence?.count ?? 0,
@@ -163,7 +163,8 @@ const CompanionPanel = (): JSX.Element => {
const progressByUnlockType: Record<string, number> = {
apotheosis: state.apotheosis?.count ?? 0,
lifetimeBosses: state.player.lifetimeBossesDefeated,
lifetimeGold: state.player.lifetimeGoldEarned,
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
lifetimeGold: state.player.lifetimeGoldEarned + state.player.totalGoldEarned,
lifetimeQuests: state.player.lifetimeQuestsCompleted,
prestige: state.prestige.count,
transcendence: state.transcendence?.count ?? 0,
+3 -1
View File
@@ -114,6 +114,9 @@ const QuestCard = ({
}
<div className="quest-rewards">
{quest.rewards.map((reward, rewardIndex) => {
if (reward.type === "crystals" && (reward.amount ?? 0) === 0) {
return null;
}
return (
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
{reward.type === "gold"
@@ -121,7 +124,6 @@ const QuestCard = ({
{reward.type === "essence"
&& `${formatNumber(reward.amount ?? 0)}`}
{reward.type === "crystals"
&& (reward.amount ?? 0) > 0
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
{reward.type === "upgrade" && "🔓 Upgrade"}
{reward.type === "adventurer" && "👥 New Adventurer"}
+1 -1
View File
@@ -218,7 +218,7 @@ const ResourceBar = ({
: ""}`}>
<span className="resource-icon">{"💎"}</span>
<span className="resource-value">
{formatInteger(crystals)}
{formatNumber(crystals)}
</span>
<span className="resource-label">{"Crystals"}</span>
{crystalsFull
+10 -2
View File
@@ -1356,10 +1356,11 @@ export const GameProvider = ({
newlyFailedQuestsReference.current = [];
}
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions)
// Auto-save every 30 seconds (skip if a force sync or auto-prestige is in-flight to avoid signature collisions)
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
lastSaveReference.current = Date.now();
if (stateReference.current !== null && !isSyncingReference.current) {
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
if (stateReference.current !== null && !isSyncingReference.current && !isAutoPrestigingReference.current) {
void saveGame({
state: stateReference.current,
...signatureReference.current === null
@@ -1856,6 +1857,13 @@ export const GameProvider = ({
},
};
});
/*
* Buying a prestige upgrade mutates DB state; clear the cached signature
* so the next auto-save doesn't collide with a stale one.
*/
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
} catch (error_: unknown) {
logError("buy_prestige_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI