feat: achievement timestamps, companion progress bars, max runestone indicator, and auto-prestige max-runes mode
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m5s
CI / Lint, Build & Test (pull_request) Failing after 1m13s

This commit is contained in:
2026-04-06 14:09:38 -07:00
committed by Naomi Carrigan
parent c494cf9a26
commit d51d7e8432
7 changed files with 169 additions and 27 deletions
+4
View File
@@ -189,6 +189,7 @@ const buildPostPrestigeState = (
} => { } => {
const { const {
autoPrestigeEnabled, autoPrestigeEnabled,
autoPrestigeMaxRunestonesOnly,
count: currentPrestigeCount, count: currentPrestigeCount,
purchasedUpgradeIds, purchasedUpgradeIds,
runestones: currentRunestones, runestones: currentRunestones,
@@ -215,6 +216,9 @@ const buildPostPrestigeState = (
...autoPrestigeEnabled === undefined ...autoPrestigeEnabled === undefined
? {} ? {}
: { autoPrestigeEnabled }, : { autoPrestigeEnabled },
...autoPrestigeMaxRunestonesOnly === undefined
? {}
: { autoPrestigeMaxRunestonesOnly },
}; };
const freshState = initialGameState(currentState.player, characterName); const freshState = initialGameState(currentState.player, characterName);
+14
View File
@@ -255,6 +255,20 @@ describe("buildPostPrestigeState", () => {
expect(prestigeData.autoPrestigeEnabled).toBeUndefined(); expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
}); });
it("preserves autoPrestigeMaxRunestonesOnly when set", () => {
const state = makeMinimalState({
prestige: { autoPrestigeMaxRunestonesOnly: true, count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
});
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBe(true);
});
it("omits autoPrestigeMaxRunestonesOnly when not set", () => {
const state = makeMinimalState();
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBeUndefined();
});
it("preserves apotheosis data across prestige", () => { it("preserves apotheosis data across prestige", () => {
const apotheosis = { count: 2 }; const apotheosis = { count: 2 };
const state = makeMinimalState({ apotheosis }); const state = makeMinimalState({ apotheosis });
@@ -156,7 +156,18 @@ const AchievementCard = ({
</div> </div>
<div className="achievement-status"> <div className="achievement-status">
{isUnlocked {isUnlocked
? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span> ? <>
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
{achievement.unlockedAt !== null
&& <span className="achievement-unlocked-at">
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
}
</>
: <span className="achievement-locked-badge">{"🔒"}</span> : <span className="achievement-locked-badge">{"🔒"}</span>
} }
</div> </div>
+42 -10
View File
@@ -30,11 +30,12 @@ const unlockLabels: Record<string, string> = {
}; };
interface CompanionCardProperties { interface CompanionCardProperties {
readonly companion: Companion; readonly companion: Companion;
readonly isUnlocked: boolean; readonly isUnlocked: boolean;
readonly isActive: boolean; readonly isActive: boolean;
readonly onSelect: ()=> void; readonly onSelect: ()=> void;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
readonly currentProgress: number;
} }
/** /**
@@ -45,6 +46,7 @@ interface CompanionCardProperties {
* @param props.isActive - Whether this companion is currently active. * @param props.isActive - Whether this companion is currently active.
* @param props.onSelect - Callback when the companion is selected/deselected. * @param props.onSelect - Callback when the companion is selected/deselected.
* @param props.formatNumber - The number formatting utility function. * @param props.formatNumber - The number formatting utility function.
* @param props.currentProgress - The player's current progress toward the unlock threshold.
* @returns The JSX element. * @returns The JSX element.
*/ */
const CompanionCard = ({ const CompanionCard = ({
@@ -53,6 +55,7 @@ const CompanionCard = ({
isActive, isActive,
onSelect, onSelect,
formatNumber, formatNumber,
currentProgress,
}: CompanionCardProperties): JSX.Element => { }: CompanionCardProperties): JSX.Element => {
const bonusSign = companion.bonus.type === "questTime" const bonusSign = companion.bonus.type === "questTime"
? "-" ? "-"
@@ -111,11 +114,28 @@ const CompanionCard = ({
: "Activate"} : "Activate"}
</button> </button>
: <div className="companion-unlock-requirement"> : <div className="companion-unlock-requirement">
{"🔒 Unlock: "} <p>
{companion.unlock.type === "lifetimeGold" {"🔒 Unlock: "}
? formatNumber(companion.unlock.threshold) {companion.unlock.type === "lifetimeGold"
: String(companion.unlock.threshold)}{" "} ? formatNumber(companion.unlock.threshold)
{unlockLabels[companion.unlock.type] ?? companion.unlock.type} : String(companion.unlock.threshold)}{" "}
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
</p>
<div className="companion-progress">
<progress
max={companion.unlock.threshold}
value={Math.min(currentProgress, companion.unlock.threshold)}
/>
<span className="companion-progress-label">
{companion.unlock.type === "lifetimeGold"
? formatNumber(currentProgress)
: String(currentProgress)}
{" / "}
{companion.unlock.type === "lifetimeGold"
? formatNumber(companion.unlock.threshold)
: String(companion.unlock.threshold)}
</span>
</div>
</div> </div>
} }
</div> </div>
@@ -140,6 +160,15 @@ const CompanionPanel = (): JSX.Element => {
const unlockedIds = state.companions?.unlockedCompanionIds ?? []; const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
const activeId = state.companions?.activeCompanionId ?? null; const activeId = state.companions?.activeCompanionId ?? null;
const progressByUnlockType: Record<string, number> = {
apotheosis: state.apotheosis?.count ?? 0,
lifetimeBosses: state.player.lifetimeBossesDefeated,
lifetimeGold: state.player.lifetimeGoldEarned,
lifetimeQuests: state.player.lifetimeQuestsCompleted,
prestige: state.prestige.count,
transcendence: state.transcendence?.count ?? 0,
};
function handleSelect(companionId: string): void { function handleSelect(companionId: string): void {
setActiveCompanion(activeId === companionId setActiveCompanion(activeId === companionId
? null ? null
@@ -177,6 +206,9 @@ const CompanionPanel = (): JSX.Element => {
return ( return (
<CompanionCard <CompanionCard
companion={companion} companion={companion}
currentProgress={
progressByUnlockType[companion.unlock.type] ?? 0
}
formatNumber={formatNumber} formatNumber={formatNumber}
isActive={activeId === companion.id} isActive={activeId === companion.id}
isUnlocked={unlockedIds.includes(companion.id)} isUnlocked={unlockedIds.includes(companion.id)}
+52 -12
View File
@@ -68,6 +68,7 @@ const PrestigePanel = (): JSX.Element => {
enableSounds, enableSounds,
toggleAutoAdventurer, toggleAutoAdventurer,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones,
triggerPrestigeToast, triggerPrestigeToast,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
@@ -93,6 +94,11 @@ const PrestigePanel = (): JSX.Element => {
const isEligible = player.totalGoldEarned >= threshold; const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = computeProjectedRunestones(state); const runestonePreview = computeProjectedRunestones(state);
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1); const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
const baseRunestones = Math.min(
Math.floor(Math.cbrt(player.totalGoldEarned / threshold)) * 15,
200,
);
const isAtMaxRunestones = baseRunestones >= 200;
async function handlePrestige(): Promise<void> { async function handlePrestige(): Promise<void> {
setIsPending(true); setIsPending(true);
@@ -155,6 +161,10 @@ const PrestigePanel = (): JSX.Element => {
toggleAutoPrestige(); toggleAutoPrestige();
} }
function handleAutoPrestigeMaxRunestonesToggle(): void {
toggleAutoPrestigeMaxRunestones();
}
function handlePrestigeTabClick(): void { function handlePrestigeTabClick(): void {
setActiveTab("prestige"); setActiveTab("prestige");
} }
@@ -241,6 +251,16 @@ const PrestigePanel = (): JSX.Element => {
{"+"} {"+"}
{formatInteger(runestonePreview)} {formatInteger(runestonePreview)}
</strong> </strong>
{isAtMaxRunestones
? <span className="runestone-max-badge">{" ⚡ MAX"}</span>
: null
}
</p>
: null}
{isEligible && !isAtMaxRunestones
? <p className="runestone-progress-hint">
{"Earn more gold to increase your runestone yield "
+ "(capped at ×14³ the prestige threshold)."}
</p> </p>
: null} : null}
{isEligible {isEligible
@@ -332,6 +352,8 @@ const PrestigePanel = (): JSX.Element => {
= upgrade.id === "auto_prestige" && purchased; = upgrade.id === "auto_prestige" && purchased;
const autoPrestigeEnabled const autoPrestigeEnabled
= prestigeData.autoPrestigeEnabled ?? false; = prestigeData.autoPrestigeEnabled ?? false;
const autoPrestigeMaxRunestonesOnly
= prestigeData.autoPrestigeMaxRunestonesOnly ?? false;
function handleBuyClick(): void { function handleBuyClick(): void {
void handleBuyUpgrade(upgrade.id); void handleBuyUpgrade(upgrade.id);
@@ -378,19 +400,37 @@ const PrestigePanel = (): JSX.Element => {
</button> </button>
: null} : null}
{isAutoPrestigeToggle {isAutoPrestigeToggle
? <button ? <>
className={`auto-prestige-toggle ${ <button
autoPrestigeEnabled className={`auto-prestige-toggle ${
? "enabled" autoPrestigeEnabled
: "disabled" ? "enabled"
}`} : "disabled"
onClick={handleAutoPrestigeToggle} }`}
type="button" onClick={handleAutoPrestigeToggle}
> type="button"
>
{autoPrestigeEnabled
? "⚡ Auto ON"
: "⏸ Auto OFF"}
</button>
{autoPrestigeEnabled {autoPrestigeEnabled
? "⚡ Auto ON" ? <button
: "⏸ Auto OFF"} className={`auto-prestige-toggle ${
</button> autoPrestigeMaxRunestonesOnly
? "enabled"
: "disabled"
}`}
onClick={handleAutoPrestigeMaxRunestonesToggle}
title="Only fire at max runestone yield"
type="button"
>
{autoPrestigeMaxRunestonesOnly
? "⚡ Max Runes Only"
: "⏸ Max Runes OFF"}
</button>
: null}
</>
: null} : null}
{purchased {purchased
? null ? null
+39 -4
View File
@@ -479,6 +479,11 @@ interface GameContextValue {
*/ */
toggleAutoPrestige: ()=> void; toggleAutoPrestige: ()=> void;
/**
* Toggle whether auto-prestige waits for maximum runestone yield before firing.
*/
toggleAutoPrestigeMaxRunestones: ()=> void;
/** /**
* Toggle the auto-quest setting on/off. * Toggle the auto-quest setting on/off.
*/ */
@@ -1391,15 +1396,27 @@ export const GameProvider = ({
// Auto-prestige: fire when unlocked, enabled, and threshold is met // Auto-prestige: fire when unlocked, enabled, and threshold is met
const autoState = stateReference.current; const autoState = stateReference.current;
const autoPrestigeThreshold = autoPrestigeThresholdBase
* Math.pow((autoState?.prestige.count ?? 0) + 1, 2.5)
* (autoState?.transcendence?.echoPrestigeThresholdMultiplier ?? 1);
const autoBaseRunestones = Math.min(
Math.floor(
Math.cbrt(
(autoState?.player.totalGoldEarned ?? 0) / autoPrestigeThreshold,
),
) * 15,
200,
);
const autoMaxRunestonesMet
= autoState?.prestige.autoPrestigeMaxRunestonesOnly !== true
|| autoBaseRunestones >= 200;
if ( if (
!isAutoPrestigingReference.current !isAutoPrestigingReference.current
&& autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige") && autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige")
=== true === true
&& autoState.prestige.autoPrestigeEnabled === true && autoState.prestige.autoPrestigeEnabled === true
&& autoState.player.totalGoldEarned && autoState.player.totalGoldEarned >= autoPrestigeThreshold
>= autoPrestigeThresholdBase && autoMaxRunestonesMet
* Math.pow(autoState.prestige.count + 1, 2.5)
* (autoState.transcendence?.echoPrestigeThresholdMultiplier ?? 1)
) { ) {
isAutoPrestigingReference.current = true; isAutoPrestigingReference.current = true;
void prestigeApi({}). void prestigeApi({}).
@@ -2065,6 +2082,22 @@ export const GameProvider = ({
}); });
}, []); }, []);
const toggleAutoPrestigeMaxRunestones = useCallback(() => {
setState((previous) => {
if (previous === null) {
return previous;
}
return {
...previous,
prestige: {
...previous.prestige,
autoPrestigeMaxRunestonesOnly:
previous.prestige.autoPrestigeMaxRunestonesOnly !== true,
},
};
});
}, []);
const toggleAutoQuest = useCallback(() => { const toggleAutoQuest = useCallback(() => {
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
@@ -2496,6 +2529,7 @@ export const GameProvider = ({
toggleAutoAdventurer, toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones,
toggleAutoQuest, toggleAutoQuest,
transcend, transcend,
triggerPrestigeToast, triggerPrestigeToast,
@@ -2571,6 +2605,7 @@ export const GameProvider = ({
toggleAutoAdventurer, toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones,
toggleAutoQuest, toggleAutoQuest,
transcend, transcend,
triggerPrestigeToast, triggerPrestigeToast,
@@ -56,6 +56,12 @@ interface PrestigeData {
* Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade). * Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade).
*/ */
autoPrestigeEnabled?: boolean; autoPrestigeEnabled?: boolean;
/**
* When true, auto-prestige only fires when the runestone yield is at its maximum base cap.
* Requires auto_prestige upgrade and autoPrestigeEnabled to be true.
*/
autoPrestigeMaxRunestonesOnly?: boolean;
} }
export type { PrestigeData }; export type { PrestigeData };