From d51d7e8432028ffcfb089a3697d902b0e4157f84 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 6 Apr 2026 14:09:38 -0700 Subject: [PATCH] feat: achievement timestamps, companion progress bars, max runestone indicator, and auto-prestige max-runes mode --- apps/api/src/services/prestige.ts | 4 ++ apps/api/test/services/prestige.spec.ts | 14 ++++ .../src/components/game/achievementPanel.tsx | 13 +++- .../src/components/game/companionPanel.tsx | 52 ++++++++++++--- .../web/src/components/game/prestigePanel.tsx | 64 +++++++++++++++---- apps/web/src/context/gameContext.tsx | 43 +++++++++++-- packages/types/src/interfaces/prestige.ts | 6 ++ 7 files changed, 169 insertions(+), 27 deletions(-) diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 372c4ee..4fd106e 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -189,6 +189,7 @@ const buildPostPrestigeState = ( } => { const { autoPrestigeEnabled, + autoPrestigeMaxRunestonesOnly, count: currentPrestigeCount, purchasedUpgradeIds, runestones: currentRunestones, @@ -215,6 +216,9 @@ const buildPostPrestigeState = ( ...autoPrestigeEnabled === undefined ? {} : { autoPrestigeEnabled }, + ...autoPrestigeMaxRunestonesOnly === undefined + ? {} + : { autoPrestigeMaxRunestonesOnly }, }; const freshState = initialGameState(currentState.player, characterName); diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 8e7479e..90fbddf 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -255,6 +255,20 @@ describe("buildPostPrestigeState", () => { 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", () => { const apotheosis = { count: 2 }; const state = makeMinimalState({ apotheosis }); diff --git a/apps/web/src/components/game/achievementPanel.tsx b/apps/web/src/components/game/achievementPanel.tsx index 8cb5d53..e718c91 100644 --- a/apps/web/src/components/game/achievementPanel.tsx +++ b/apps/web/src/components/game/achievementPanel.tsx @@ -156,7 +156,18 @@ const AchievementCard = ({
{isUnlocked - ? {"✓ Unlocked"} + ? <> + {"✓ Unlocked"} + {achievement.unlockedAt !== null + && + {new Date(achievement.unlockedAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + + } + : {"🔒"} }
diff --git a/apps/web/src/components/game/companionPanel.tsx b/apps/web/src/components/game/companionPanel.tsx index d664f80..a770a60 100644 --- a/apps/web/src/components/game/companionPanel.tsx +++ b/apps/web/src/components/game/companionPanel.tsx @@ -30,11 +30,12 @@ const unlockLabels: Record = { }; interface CompanionCardProperties { - readonly companion: Companion; - readonly isUnlocked: boolean; - readonly isActive: boolean; - readonly onSelect: ()=> void; - readonly formatNumber: (n: number)=> string; + readonly companion: Companion; + readonly isUnlocked: boolean; + readonly isActive: boolean; + readonly onSelect: ()=> void; + 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.onSelect - Callback when the companion is selected/deselected. * @param props.formatNumber - The number formatting utility function. + * @param props.currentProgress - The player's current progress toward the unlock threshold. * @returns The JSX element. */ const CompanionCard = ({ @@ -53,6 +55,7 @@ const CompanionCard = ({ isActive, onSelect, formatNumber, + currentProgress, }: CompanionCardProperties): JSX.Element => { const bonusSign = companion.bonus.type === "questTime" ? "-" @@ -111,11 +114,28 @@ const CompanionCard = ({ : "Activate"} :
- {"🔒 Unlock: "} - {companion.unlock.type === "lifetimeGold" - ? formatNumber(companion.unlock.threshold) - : String(companion.unlock.threshold)}{" "} - {unlockLabels[companion.unlock.type] ?? companion.unlock.type} +

+ {"🔒 Unlock: "} + {companion.unlock.type === "lifetimeGold" + ? formatNumber(companion.unlock.threshold) + : String(companion.unlock.threshold)}{" "} + {unlockLabels[companion.unlock.type] ?? companion.unlock.type} +

+
+ + + {companion.unlock.type === "lifetimeGold" + ? formatNumber(currentProgress) + : String(currentProgress)} + {" / "} + {companion.unlock.type === "lifetimeGold" + ? formatNumber(companion.unlock.threshold) + : String(companion.unlock.threshold)} + +
} @@ -140,6 +160,15 @@ const CompanionPanel = (): JSX.Element => { const unlockedIds = state.companions?.unlockedCompanionIds ?? []; const activeId = state.companions?.activeCompanionId ?? null; + const progressByUnlockType: Record = { + 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 { setActiveCompanion(activeId === companionId ? null @@ -177,6 +206,9 @@ const CompanionPanel = (): JSX.Element => { return ( { enableSounds, toggleAutoAdventurer, toggleAutoPrestige, + toggleAutoPrestigeMaxRunestones, triggerPrestigeToast, } = useGame(); const [ isPending, setIsPending ] = useState(false); @@ -93,6 +94,11 @@ const PrestigePanel = (): JSX.Element => { const isEligible = player.totalGoldEarned >= threshold; const runestonePreview = computeProjectedRunestones(state); 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 { setIsPending(true); @@ -155,6 +161,10 @@ const PrestigePanel = (): JSX.Element => { toggleAutoPrestige(); } + function handleAutoPrestigeMaxRunestonesToggle(): void { + toggleAutoPrestigeMaxRunestones(); + } + function handlePrestigeTabClick(): void { setActiveTab("prestige"); } @@ -241,6 +251,16 @@ const PrestigePanel = (): JSX.Element => { {"+"} {formatInteger(runestonePreview)} + {isAtMaxRunestones + ? {" ⚡ MAX"} + : null + } +

+ : null} + {isEligible && !isAtMaxRunestones + ?

+ {"Earn more gold to increase your runestone yield " + + "(capped at ×14³ the prestige threshold)."}

: null} {isEligible @@ -332,6 +352,8 @@ const PrestigePanel = (): JSX.Element => { = upgrade.id === "auto_prestige" && purchased; const autoPrestigeEnabled = prestigeData.autoPrestigeEnabled ?? false; + const autoPrestigeMaxRunestonesOnly + = prestigeData.autoPrestigeMaxRunestonesOnly ?? false; function handleBuyClick(): void { void handleBuyUpgrade(upgrade.id); @@ -378,19 +400,37 @@ const PrestigePanel = (): JSX.Element => { : null} {isAutoPrestigeToggle - ? {autoPrestigeEnabled - ? "⚡ Auto ON" - : "⏸ Auto OFF"} - + ? + : null} + : null} {purchased ? null diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index cf16a9d..e54bd4e 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -479,6 +479,11 @@ interface GameContextValue { */ toggleAutoPrestige: ()=> void; + /** + * Toggle whether auto-prestige waits for maximum runestone yield before firing. + */ + toggleAutoPrestigeMaxRunestones: ()=> void; + /** * Toggle the auto-quest setting on/off. */ @@ -1391,15 +1396,27 @@ export const GameProvider = ({ // Auto-prestige: fire when unlocked, enabled, and threshold is met 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 ( !isAutoPrestigingReference.current && autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige") === true && autoState.prestige.autoPrestigeEnabled === true - && autoState.player.totalGoldEarned - >= autoPrestigeThresholdBase - * Math.pow(autoState.prestige.count + 1, 2.5) - * (autoState.transcendence?.echoPrestigeThresholdMultiplier ?? 1) + && autoState.player.totalGoldEarned >= autoPrestigeThreshold + && autoMaxRunestonesMet ) { isAutoPrestigingReference.current = true; 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(() => { setState((previous) => { if (previous === null) { @@ -2496,6 +2529,7 @@ export const GameProvider = ({ toggleAutoAdventurer, toggleAutoBoss, toggleAutoPrestige, + toggleAutoPrestigeMaxRunestones, toggleAutoQuest, transcend, triggerPrestigeToast, @@ -2571,6 +2605,7 @@ export const GameProvider = ({ toggleAutoAdventurer, toggleAutoBoss, toggleAutoPrestige, + toggleAutoPrestigeMaxRunestones, toggleAutoQuest, transcend, triggerPrestigeToast, diff --git a/packages/types/src/interfaces/prestige.ts b/packages/types/src/interfaces/prestige.ts index cab808c..1050a9e 100644 --- a/packages/types/src/interfaces/prestige.ts +++ b/packages/types/src/interfaces/prestige.ts @@ -56,6 +56,12 @@ interface PrestigeData { * Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade). */ 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 };