diff --git a/src/components/upgrades/BoosterCard.tsx b/src/components/upgrades/BoosterCard.tsx index e2b88fa..0916749 100644 --- a/src/components/upgrades/BoosterCard.tsx +++ b/src/components/upgrades/BoosterCard.tsx @@ -1,4 +1,12 @@ -import { Badge, Button, Card, Group, Text } from "@mantine/core"; +import { + ActionIcon, + Badge, + Button, + Card, + Group, + Popover, + Text, +} from "@mantine/core"; import { notifications } from "@mantine/notifications"; import type { DecimalSource } from "break_infinity.js"; import { useCallback, useRef, useState } from "react"; @@ -6,6 +14,7 @@ import type { Booster } from "../../data/boosters"; import { useReducedMotion } from "../../hooks/useReducedMotion"; import { D } from "../../utils/decimal"; import { formatNumber } from "../../utils/formatNumber"; +import { BoosterTooltipContent } from "./BoosterTooltipContent"; interface BoosterCardProps { booster: Booster; @@ -25,6 +34,8 @@ export function BoosterCard({ const canAfford = !purchased && D(trainingData).gte(booster.cost); const locked = evolutionStage < booster.unlockStage; const [isGlowing, setIsGlowing] = useState(false); + const [tooltipOpen, setTooltipOpen] = useState(false); + const isHoverDevice = useRef(false); const glowTimerRef = useRef>(undefined); const prefersReduced = useReducedMotion(); @@ -46,53 +57,88 @@ export function BoosterCard({ if (locked) return null; return ( - - - - {booster.icon} {booster.name} - - {purchased && ( - - ACTIVE - - )} - + + { + isHoverDevice.current = true; + setTooltipOpen(true); + }} + onMouseLeave={() => setTooltipOpen(false)} + className={isGlowing ? "glow-pulse" : undefined} + padding="sm" + radius="sm" + withBorder + style={{ + borderColor: purchased + ? "var(--mantine-color-violet-8)" + : canAfford + ? "var(--mantine-color-green-8)" + : "var(--mantine-color-dark-4)", + opacity: purchased ? 0.7 : canAfford ? 1 : 0.5, + animation: isGlowing ? "glow-pulse 0.6s ease-in-out" : undefined, + }} + > + + + {booster.icon} {booster.name} + + + {purchased && ( + + ACTIVE + + )} + { + e.stopPropagation(); + if (!isHoverDevice.current) { + setTooltipOpen((o) => !o); + } + }} + > + ℹ + + + - - {booster.description} - + + {booster.description} + - - - ×{booster.multiplier} all auto-gen - - {!purchased && ( - - )} - - + + + ×{booster.multiplier} all auto-gen + + {!purchased && ( + + )} + + + + + + + ); } diff --git a/src/components/upgrades/BoosterTooltipContent.tsx b/src/components/upgrades/BoosterTooltipContent.tsx new file mode 100644 index 0000000..b2d95c0 --- /dev/null +++ b/src/components/upgrades/BoosterTooltipContent.tsx @@ -0,0 +1,100 @@ +import { Divider, Group, Stack, Text } from "@mantine/core"; +import type { Booster } from "../../data/boosters"; +import { BOOSTERS } from "../../data/boosters"; +import { getIdleBoostMultiplier } from "../../data/prestigeShop"; +import { getSpeciesBonus } from "../../data/species"; +import { UPGRADES } from "../../data/upgrades"; +import { + computeBoosterMultiplier, + getTotalTdPerSecond, +} from "../../engine/upgradeEngine"; +import { useGameStore } from "../../store"; +import { formatNumber } from "../../utils/formatNumber"; +import { computeGlobalMultiplierTooltipData } from "./tooltipHelpers"; + +interface BoosterTooltipContentProps { + booster: Booster; + purchased: boolean; +} + +export function BoosterTooltipContent({ + booster, + purchased, +}: BoosterTooltipContentProps) { + const upgradeOwned = useGameStore((s) => s.upgradeOwned); + const boostersPurchased = useGameStore((s) => s.boostersPurchased); + const prestigeUpgrades = useGameStore((s) => s.prestigeUpgrades); + const currentSpecies = useGameStore((s) => s.currentSpecies); + const activeChallengeId = useGameStore((s) => s.activeChallengeId); + + // Mirror the no-prestige challenge logic from the game engine + const effectivePrestige = + activeChallengeId === "no-prestige" ? {} : prestigeUpgrades; + const idleBoost = getIdleBoostMultiplier( + (effectivePrestige as Record)["idle-boost"] ?? 0, + ); + const speciesBonus = getSpeciesBonus(currentSpecies); + const boosterMult = computeBoosterMultiplier(BOOSTERS, boostersPurchased); + const currentTdPerSecond = getTotalTdPerSecond( + UPGRADES, + upgradeOwned, + idleBoost * speciesBonus.autoGen, + boosterMult, + ); + + const { currentTdPerSecond: current, newTdPerSecond } = + computeGlobalMultiplierTooltipData(booster, currentTdPerSecond); + + return ( + + + {booster.icon} {booster.name} + + + + + {booster.description} + + + + + Multiplier + + + ×{booster.multiplier} all TD/s + + + + {!purchased && ( + <> + + + + Current TD/s + + + {formatNumber(current)} + + + + + + After purchase + + + {formatNumber(newTdPerSecond)} TD/s + + + + )} + + ); +} diff --git a/src/components/upgrades/ClickUpgradeTooltipContent.tsx b/src/components/upgrades/ClickUpgradeTooltipContent.tsx index ebbe11b..e528a6d 100644 --- a/src/components/upgrades/ClickUpgradeTooltipContent.tsx +++ b/src/components/upgrades/ClickUpgradeTooltipContent.tsx @@ -1,6 +1,20 @@ import { Badge, Divider, Group, Stack, Text } from "@mantine/core"; +import { BOOSTERS } from "../../data/boosters"; import type { ClickUpgrade } from "../../data/clickUpgrades"; +import { CLICK_UPGRADES } from "../../data/clickUpgrades"; +import { + getClickMasteryBonus, + getIdleBoostMultiplier, +} from "../../data/prestigeShop"; +import { getSpeciesBonus } from "../../data/species"; +import { UPGRADES } from "../../data/upgrades"; +import { + computeBoosterMultiplier, + getTotalTdPerSecond, +} from "../../engine/upgradeEngine"; +import { useGameStore } from "../../store"; import { formatNumber } from "../../utils/formatNumber"; +import { computeClickBonusTooltipData } from "./tooltipHelpers"; interface ClickUpgradeTooltipContentProps { upgrade: ClickUpgrade; @@ -11,8 +25,42 @@ export function ClickUpgradeTooltipContent({ upgrade, purchased, }: ClickUpgradeTooltipContentProps) { + const clickUpgradesPurchased = useGameStore((s) => s.clickUpgradesPurchased); + const upgradeOwned = useGameStore((s) => s.upgradeOwned); + const boostersPurchased = useGameStore((s) => s.boostersPurchased); + const prestigeUpgrades = useGameStore((s) => s.prestigeUpgrades); + const currentSpecies = useGameStore((s) => s.currentSpecies); + const activeChallengeId = useGameStore((s) => s.activeChallengeId); + + // Mirror the no-prestige challenge logic from the game engine + const effectivePrestige = + activeChallengeId === "no-prestige" ? {} : prestigeUpgrades; + const idleBoost = getIdleBoostMultiplier( + (effectivePrestige as Record)["idle-boost"] ?? 0, + ); + const speciesBonus = getSpeciesBonus(currentSpecies); + const boosterMult = computeBoosterMultiplier(BOOSTERS, boostersPurchased); + const tdPerSecond = getTotalTdPerSecond( + UPGRADES, + upgradeOwned, + idleBoost * speciesBonus.autoGen, + boosterMult, + ); + const clickMasteryBonus = getClickMasteryBonus( + (effectivePrestige as Record)["click-mastery"] ?? 0, + ); + + const { deltaClickPower } = computeClickBonusTooltipData( + upgrade, + clickUpgradesPurchased, + CLICK_UPGRADES, + tdPerSecond, + clickMasteryBonus, + speciesBonus.clickPower, + ); + return ( - + {upgrade.icon} {upgrade.name} @@ -39,14 +87,33 @@ export function ClickUpgradeTooltipContent({ {!purchased && ( - - - Cost - - - {formatNumber(upgrade.cost)} TD - - + <> + + + Click bonus + + + +{formatNumber(deltaClickPower)} TD/click + + + + + + Cost + + + {formatNumber(upgrade.cost)} TD + + + )} ); diff --git a/src/components/upgrades/tooltipHelpers.test.ts b/src/components/upgrades/tooltipHelpers.test.ts index fd5a1ba..1c07018 100644 --- a/src/components/upgrades/tooltipHelpers.test.ts +++ b/src/components/upgrades/tooltipHelpers.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; +import { BOOSTERS } from "../../data/boosters"; +import { CLICK_UPGRADES } from "../../data/clickUpgrades"; import { UPGRADES } from "../../data/upgrades"; -import { computeGeneratorTooltipData } from "./tooltipHelpers"; +import { + computeClickBonusTooltipData, + computeGeneratorTooltipData, + computeGlobalMultiplierTooltipData, +} from "./tooltipHelpers"; const neuralNotepad = UPGRADES.find((u) => u.id === "neural-notepad"); if (!neuralNotepad) @@ -146,13 +152,14 @@ describe("computeGeneratorTooltipData", () => { }); it("accounts for milestone crossing when buying the 50th unit (49 → 50)", () => { - // Buying the 50th unit crosses the x3 milestone. - // Current: 0.2 * 49 * 2 = 19.6 - // Future: 0.2 * 50 * 3 = 30.0 - // Delta: 30.0 - 19.6 = 10.4 + // Buying the 50th unit crosses the x3 milestone AND triggers the + // neural-notepad self-synergy (+100%, ×2) simultaneously. + // Current (49 owned): synergyMultiplier=1 → 0.2 * 49 * 2 * 1 = 19.6 + // Future (50 owned): synergyMultiplier=2 → 0.2 * 50 * 3 * 2 = 60.0 + // Delta: 60.0 - 19.6 = 40.4 const allOwned = { "neural-notepad": 49 }; const data = computeGeneratorTooltipData(neuralNotepad, 49, allOwned); - expect(data.deltaTdPerSecond).toBeCloseTo(10.4); + expect(data.deltaTdPerSecond).toBeCloseTo(40.4); expect(data.milestoneWillCross).toBe(true); }); @@ -169,12 +176,13 @@ describe("computeGeneratorTooltipData", () => { it("applies normal delta at max milestone (owned=100, 100 → 101)", () => { // x6 milestone active, no further milestones. - // Current: 0.2 * 100 * 6 = 120.0 - // Future: 0.2 * 101 * 6 = 121.2 - // Delta: 1.2 + // The neural-notepad self-synergy (+100%, ×2) is active at 100 owned (≥50). + // Current: 0.2 * 100 * 6 * 2 = 240.0 + // Future: 0.2 * 101 * 6 * 2 = 242.4 + // Delta: 2.4 const allOwned = { "neural-notepad": 100 }; const data = computeGeneratorTooltipData(neuralNotepad, 100, allOwned); - expect(data.deltaTdPerSecond).toBeCloseTo(1.2); + expect(data.deltaTdPerSecond).toBeCloseTo(2.4); expect(data.milestoneWillCross).toBe(false); }); @@ -186,3 +194,134 @@ describe("computeGeneratorTooltipData", () => { }); }); }); + +// ── computeClickBonusTooltipData tests ─────────────────────────────────────── + +const betterDataset = CLICK_UPGRADES.find((u) => u.id === "better-dataset"); +if (!betterDataset) + throw new Error("better-dataset click upgrade not found in CLICK_UPGRADES"); +const stackOverflow = CLICK_UPGRADES.find((u) => u.id === "stack-overflow"); +if (!stackOverflow) + throw new Error("stack-overflow click upgrade not found in CLICK_UPGRADES"); + +describe("computeClickBonusTooltipData", () => { + it("delta is 0 when tdPerSecond is 0 and floor of 1 already applied to both", () => { + // Both current and future floor to 1, so delta is 0 + const data = computeClickBonusTooltipData( + betterDataset, + [], + CLICK_UPGRADES, + 0, + ); + expect(data.currentClickPower.toNumber()).toBe(1); + expect(data.futureClickPower.toNumber()).toBe(1); + expect(data.deltaClickPower.toNumber()).toBe(0); + }); + + it("returns correct delta when tdPerSecond is large enough to escape the floor", () => { + // BASE_CLICK_SECONDS = 0.05; betterDataset.clickSeconds = 0.1; tdPerSecond = 100 + // currentBase = 0.05 * 100 = 5 → max(1,5) = 5 + // futureBase = 0.15 * 100 = 15 → max(1,15) = 15 + // delta = 10 + const data = computeClickBonusTooltipData( + betterDataset, + [], + CLICK_UPGRADES, + 100, + ); + expect(data.currentClickPower.toNumber()).toBeCloseTo(5); + expect(data.futureClickPower.toNumber()).toBeCloseTo(15); + expect(data.deltaClickPower.toNumber()).toBeCloseTo(10); + }); + + it("already-purchased upgrade reduces future seconds correctly", () => { + // betterDataset already purchased; buying stackOverflow (+0.15s) + // currentSeconds = 0.05 + 0.1 = 0.15, tdPerSecond = 100 + // currentBase = 0.15 * 100 = 15 + // futureBase = 0.30 * 100 = 30 + // delta = 15 + const data = computeClickBonusTooltipData( + stackOverflow, + ["better-dataset"], + CLICK_UPGRADES, + 100, + ); + expect(data.currentClickPower.toNumber()).toBeCloseTo(15); + expect(data.futureClickPower.toNumber()).toBeCloseTo(30); + expect(data.deltaClickPower.toNumber()).toBeCloseTo(15); + }); + + it("applies clickMasteryBonus to currentSeconds", () => { + // clickMasteryBonus=1 adds 0.1s to current seconds + // currentSeconds = 0.05 + 0.1 = 0.15, tdPerSecond = 100 + // futureSeconds = 0.05 + 0.1 + 0.1 = 0.25 + // delta = (0.25 - 0.15) * 100 = 10 + const data = computeClickBonusTooltipData( + betterDataset, + [], + CLICK_UPGRADES, + 100, + 1, // clickMasteryBonus + ); + expect(data.deltaClickPower.toNumber()).toBeCloseTo(10); + }); + + it("applies speciesClickMultiplier to both base values", () => { + // speciesClickMultiplier = 1.5; betterDataset; tdPerSecond = 100; no purchased + // currentBase = 0.05 * 100 * 1.5 = 7.5 + // futureBase = 0.15 * 100 * 1.5 = 22.5 + // delta = 15 + const data = computeClickBonusTooltipData( + betterDataset, + [], + CLICK_UPGRADES, + 100, + 0, + 1.5, // speciesClickMultiplier + ); + expect(data.currentClickPower.toNumber()).toBeCloseTo(7.5); + expect(data.futureClickPower.toNumber()).toBeCloseTo(22.5); + expect(data.deltaClickPower.toNumber()).toBeCloseTo(15); + }); +}); + +// ── computeGlobalMultiplierTooltipData tests ───────────────────────────────── + +const seriesAFunding = BOOSTERS.find((b) => b.id === "series-a-funding"); +if (!seriesAFunding) + throw new Error("series-a-funding booster not found in BOOSTERS"); +const hypeTrain = BOOSTERS.find((b) => b.id === "hype-train"); +if (!hypeTrain) throw new Error("hype-train booster not found in BOOSTERS"); + +describe("computeGlobalMultiplierTooltipData", () => { + it("doubles TD/s for the 2× booster", () => { + const data = computeGlobalMultiplierTooltipData(seriesAFunding, 500); + expect(data.multiplier).toBe(2); + expect(data.currentTdPerSecond.toNumber()).toBe(500); + expect(data.newTdPerSecond.toNumber()).toBe(1000); + }); + + it("triples TD/s for the 3× booster", () => { + const data = computeGlobalMultiplierTooltipData(hypeTrain, 200); + expect(data.multiplier).toBe(3); + expect(data.currentTdPerSecond.toNumber()).toBe(200); + expect(data.newTdPerSecond.toNumber()).toBe(600); + }); + + it("returns 0 new TD/s when current is 0", () => { + const data = computeGlobalMultiplierTooltipData(seriesAFunding, 0); + expect(data.currentTdPerSecond.toNumber()).toBe(0); + expect(data.newTdPerSecond.toNumber()).toBe(0); + }); + + it("preserves current TD/s unchanged in the output", () => { + const data = computeGlobalMultiplierTooltipData(seriesAFunding, 1234.5); + expect(data.currentTdPerSecond.toNumber()).toBeCloseTo(1234.5); + }); + + it("handles Decimal input for currentTdPerSecond", () => { + // Large numbers go through Decimal path + const data = computeGlobalMultiplierTooltipData(hypeTrain, 1e15); + expect(data.newTdPerSecond.toNumber()).toBeCloseTo(3e15); + }); +}); diff --git a/src/components/upgrades/tooltipHelpers.ts b/src/components/upgrades/tooltipHelpers.ts index e15569f..f845f4f 100644 --- a/src/components/upgrades/tooltipHelpers.ts +++ b/src/components/upgrades/tooltipHelpers.ts @@ -1,12 +1,17 @@ +import type { Booster } from "../../data/boosters"; +import type { ClickUpgrade } from "../../data/clickUpgrades"; import { MILESTONE_THRESHOLDS } from "../../data/milestones"; import type { Upgrade } from "../../data/upgrades"; import { UPGRADES } from "../../data/upgrades"; +import { computeClickSeconds } from "../../engine/clickEngine"; import { getMilestoneLevel, getMilestoneMultiplier, } from "../../engine/milestoneEngine"; import { getSynergyMultiplier } from "../../engine/synergyEngine"; import { getTotalTdPerSecond } from "../../engine/upgradeEngine"; +import type { DecimalSource } from "../../utils/decimal"; +import { D, Decimal } from "../../utils/decimal"; export interface GeneratorTooltipData { name: string; @@ -63,7 +68,10 @@ export function computeGeneratorTooltipData( const newMilestoneMultiplier = getMilestoneMultiplier(newOwned); const newSynergyMultiplier = getSynergyMultiplier(upgrade.id, newAllOwned); const futureTdForGenerator = - upgrade.baseTdPerSecond * newOwned * newMilestoneMultiplier * newSynergyMultiplier; + upgrade.baseTdPerSecond * + newOwned * + newMilestoneMultiplier * + newSynergyMultiplier; const deltaTdPerSecond = futureTdForGenerator - totalTdForGenerator; const milestoneWillCross = getMilestoneLevel(newOwned) > milestoneLevel; @@ -84,3 +92,81 @@ export function computeGeneratorTooltipData( milestoneWillCross, }; } + +// ── Click-bonus tooltip ─────────────────────────────────────────────────────── + +export interface ClickBonusTooltipData { + /** TD gained per click with current upgrades (no combo, floor applied). */ + currentClickPower: Decimal; + /** TD gained per click after purchasing this upgrade (no combo, floor applied). */ + futureClickPower: Decimal; + /** Net increase in click power from buying this upgrade. */ + deltaClickPower: Decimal; +} + +/** + * Computes the click-power delta shown in a click-bonus upgrade tooltip. + * + * Combo is intentionally excluded because it is transient — the tooltip + * reflects the stable base increase, not a snapshot of a lucky combo streak. + * The max(1, ...) floor matches the real engine so early-game values (where + * tdPerSecond ≈ 0) show truthful numbers. + */ +export function computeClickBonusTooltipData( + upgrade: ClickUpgrade, + purchasedIds: string[], + clickUpgrades: readonly ClickUpgrade[], + tdPerSecond: DecimalSource, + clickMasteryBonus = 0, + speciesClickMultiplier = 1, +): ClickBonusTooltipData { + const currentSeconds = computeClickSeconds( + purchasedIds, + clickUpgrades, + clickMasteryBonus, + ); + const futureSeconds = currentSeconds + upgrade.clickSeconds; + + const currentBase = D(currentSeconds) + .mul(tdPerSecond) + .mul(speciesClickMultiplier); + const futureBase = D(futureSeconds) + .mul(tdPerSecond) + .mul(speciesClickMultiplier); + + const currentClickPower = Decimal.max(1, currentBase); + const futureClickPower = Decimal.max(1, futureBase); + const deltaClickPower = futureClickPower.sub(currentClickPower); + + return { currentClickPower, futureClickPower, deltaClickPower }; +} + +// ── Global-multiplier tooltip ───────────────────────────────────────────────── + +export interface GlobalMultiplierTooltipData { + multiplier: number; + /** Total TD/s right now (with all current booster multipliers applied). */ + currentTdPerSecond: Decimal; + /** Projected total TD/s after purchasing this booster. */ + newTdPerSecond: Decimal; +} + +/** + * Computes the before/after TD/s pair for a global-multiplier (booster) tooltip. + * + * `currentTdPerSecond` must already include all active booster multipliers. + * Purchasing this booster multiplies the entire total by `booster.multiplier`, + * so `newTdPerSecond = currentTdPerSecond × booster.multiplier`. + */ +export function computeGlobalMultiplierTooltipData( + booster: Booster, + currentTdPerSecond: DecimalSource, +): GlobalMultiplierTooltipData { + const current = D(currentTdPerSecond); + const newTdPerSecond = current.mul(booster.multiplier); + return { + multiplier: booster.multiplier, + currentTdPerSecond: current, + newTdPerSecond, + }; +}