From 0fa203de332f6bd47246996b4da15bff459d3233 Mon Sep 17 00:00:00 2001 From: 4sh-dev Date: Fri, 13 Mar 2026 21:06:21 -0700 Subject: [PATCH 1/3] feat: add computeAllGeneratorsCps engine function with GeneratorCpsRow type --- src/engine/upgradeEngine.test.ts | 91 ++++++++++++++++++++++++++++++++ src/engine/upgradeEngine.ts | 50 ++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/src/engine/upgradeEngine.test.ts b/src/engine/upgradeEngine.test.ts index 04eb67e..30b1148 100644 --- a/src/engine/upgradeEngine.test.ts +++ b/src/engine/upgradeEngine.test.ts @@ -3,6 +3,7 @@ import type { Booster } from "../data/boosters"; import type { Upgrade } from "../data/upgrades"; import { COST_MULTIPLIER, + computeAllGeneratorsCps, computeBoosterMultiplier, getBulkCost, getMaxAffordable, @@ -390,3 +391,93 @@ describe("computeBoosterMultiplier", () => { ).toBe(1); }); }); + +describe("computeAllGeneratorsCps", () => { + it("returns an entry for every upgrade in the input", () => { + const rows = computeAllGeneratorsCps( + [mockUpgrade, mockUpgrade2], + {}, + ); + expect(rows).toHaveLength(2); + expect(rows[0].id).toBe("test-upgrade"); + expect(rows[1].id).toBe("test-upgrade-2"); + }); + + it("returns empty array for empty upgrades list", () => { + expect(computeAllGeneratorsCps([], {})).toHaveLength(0); + }); + + it("rows have 0 owned, 0 totalCps, 0% when nothing purchased", () => { + const rows = computeAllGeneratorsCps([mockUpgrade], {}); + expect(rows[0].owned).toBe(0); + expect(rows[0].totalCps).toBe(0); + expect(rows[0].percentOfTotal).toBe(0); + }); + + it("row carries name and icon from the upgrade definition", () => { + const rows = computeAllGeneratorsCps([mockUpgrade], {}); + expect(rows[0].name).toBe("Test Upgrade"); + expect(rows[0].icon).toBe(mockUpgrade.icon); + }); + + it("perUnitCps equals baseTdPerSecond when below first milestone (< 10)", () => { + const rows = computeAllGeneratorsCps( + [mockUpgrade], + { "test-upgrade": 3 }, + ); + expect(rows[0].perUnitCps).toBeCloseTo(mockUpgrade.baseTdPerSecond); + }); + + it("totalCps equals perUnitCps * owned", () => { + const owned = { "test-upgrade": 5 }; + const rows = computeAllGeneratorsCps([mockUpgrade], owned); + expect(rows[0].totalCps).toBeCloseTo(rows[0].perUnitCps * 5); + }); + + it("shows 100% for the only owned generator", () => { + const owned = { "test-upgrade": 4 }; + const rows = computeAllGeneratorsCps([mockUpgrade, mockUpgrade2], owned); + const active = rows.find((r) => r.id === "test-upgrade")!; + const inactive = rows.find((r) => r.id === "test-upgrade-2")!; + expect(active.percentOfTotal).toBeCloseTo(100); + expect(inactive.percentOfTotal).toBeCloseTo(0); + }); + + it("percentages across owned generators sum to 100", () => { + // test-upgrade: 2 owned x 1.5 = 3 TD/s + // test-upgrade-2: 1 owned x 5 = 5 TD/s + // total = 8, shares ≈ 37.5% and 62.5% + const owned = { "test-upgrade": 2, "test-upgrade-2": 1 }; + const rows = computeAllGeneratorsCps([mockUpgrade, mockUpgrade2], owned); + const totalPercent = rows.reduce((sum, r) => sum + r.percentOfTotal, 0); + expect(totalPercent).toBeCloseTo(100); + }); + + it("percentages reflect each generator's share correctly", () => { + const owned = { "test-upgrade": 2, "test-upgrade-2": 1 }; + const rows = computeAllGeneratorsCps([mockUpgrade, mockUpgrade2], owned); + const r1 = rows.find((r) => r.id === "test-upgrade")!; + const r2 = rows.find((r) => r.id === "test-upgrade-2")!; + // 3 / 8 * 100 = 37.5 and 5 / 8 * 100 = 62.5 + expect(r1.percentOfTotal).toBeCloseTo(37.5, 1); + expect(r2.percentOfTotal).toBeCloseTo(62.5, 1); + }); + + it("applies milestone multiplier (x1.5 at 10 owned) to perUnitCps", () => { + const owned = { "test-upgrade": 10 }; + const rows = computeAllGeneratorsCps([mockUpgrade], owned); + // baseTdPerSecond 1.5 * milestone 1.5 = 2.25 + expect(rows[0].perUnitCps).toBeCloseTo(2.25); + expect(rows[0].totalCps).toBeCloseTo(22.5); + }); + + it("excludes global multipliers (percentages unchanged by global bonus)", () => { + // Percentages are based on globalMultiplier=1 so they stay the same + // regardless of any idle/species/booster multipliers applied at render time. + const owned = { "test-upgrade": 2, "test-upgrade-2": 1 }; + const rowsBase = computeAllGeneratorsCps([mockUpgrade, mockUpgrade2], owned); + // There's no parameter for global multiplier by design — verify the function + // signature doesn't accept one, and that results are stable. + expect(rowsBase[0].percentOfTotal).toBeCloseTo(37.5, 1); + }); +}); diff --git a/src/engine/upgradeEngine.ts b/src/engine/upgradeEngine.ts index 36bba50..5dc5357 100644 --- a/src/engine/upgradeEngine.ts +++ b/src/engine/upgradeEngine.ts @@ -93,3 +93,53 @@ export function getTotalTdPerSecond( } return total.mul(globalMultiplier).mul(boosterMultiplier); } + +/** A single row in the per-generator CPS breakdown panel. */ +export interface GeneratorCpsRow { + id: string; + name: string; + icon: string; + owned: number; + /** Effective CPS for a single unit (baseTdPerSecond × milestone × synergy). */ + perUnitCps: number; + /** Total CPS contributed by all owned units of this generator. */ + totalCps: number; + /** Share of grand total CPS (0–100). */ + percentOfTotal: number; +} + +/** + * Returns a CPS breakdown row for every generator in `upgrades`. + * + * Global multipliers (idle boost, species auto-gen, booster) are intentionally + * excluded so that percentage shares are valid regardless of which global + * bonuses are active — the same exclusion used by GeneratorTooltipContent. + */ +export function computeAllGeneratorsCps( + upgrades: readonly Upgrade[], + owned: Record, +): GeneratorCpsRow[] { + const grandTotal = getTotalTdPerSecond(upgrades, owned, 1, 1); + const grandTotalNum = grandTotal.toNumber(); + + return upgrades.map((upgrade) => { + const count = owned[upgrade.id] ?? 0; + const milestoneMultiplier = getMilestoneMultiplier(count); + const synergyMultiplier = getSynergyMultiplier(upgrade.id, owned); + const perUnitCps = + upgrade.baseTdPerSecond * milestoneMultiplier * synergyMultiplier; + const totalCps = perUnitCps * count; + const percentOfTotal = + grandTotalNum > 0 ? (totalCps / grandTotalNum) * 100 : 0; + + return { + id: upgrade.id, + name: upgrade.name, + icon: upgrade.icon, + owned: count, + perUnitCps, + totalCps, + percentOfTotal, + }; + }); +} From 10357d509c5cd81b66dcdf312c165d72cbf97762 Mon Sep 17 00:00:00 2001 From: 4sh-dev Date: Fri, 13 Mar 2026 21:07:17 -0700 Subject: [PATCH 2/3] feat: add GeneratorCpsBreakdown panel and integrate into UpgradesSidebar Closes #105 --- src/components/GeneratorCpsBreakdown.tsx | 111 +++++++++++++++++++++++ src/components/UpgradesSidebar.tsx | 46 ++++++++-- src/components/index.ts | 1 + 3 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 src/components/GeneratorCpsBreakdown.tsx diff --git a/src/components/GeneratorCpsBreakdown.tsx b/src/components/GeneratorCpsBreakdown.tsx new file mode 100644 index 0000000..776b948 --- /dev/null +++ b/src/components/GeneratorCpsBreakdown.tsx @@ -0,0 +1,111 @@ +import { Badge, Group, Stack, Text } from "@mantine/core"; +import { useMemo } from "react"; +import { UPGRADES } from "../data/upgrades"; +import { computeAllGeneratorsCps } from "../engine/upgradeEngine"; +import { useGameStore } from "../store"; +import { formatNumber } from "../utils/formatNumber"; + +/** + * Renders a compact per-generator CPS breakdown table. + * Only generators with at least 1 owned unit are shown. + * Values update reactively whenever upgradeOwned changes. + */ +export function GeneratorCpsBreakdown() { + const upgradeOwned = useGameStore((s) => s.upgradeOwned); + + const rows = useMemo( + () => computeAllGeneratorsCps(UPGRADES, upgradeOwned), + [upgradeOwned], + ); + + const activeRows = rows.filter((r) => r.owned > 0); + + if (activeRows.length === 0) { + return ( + + No generators owned yet. + + ); + } + + return ( + + {/* Column headers */} + + + Generator + + + CPS + + + Share + + + + {activeRows.map((row) => ( + + {/* Icon + name + owned badge */} + + + + {row.name} + + + ×{row.owned} + + + + {/* Total CPS */} + + {formatNumber(row.totalCps)}/s + + + {/* Percentage share */} + + {row.percentOfTotal.toFixed(1)}% + + + ))} + + ); +} diff --git a/src/components/UpgradesSidebar.tsx b/src/components/UpgradesSidebar.tsx index 7128b1d..6aedc53 100644 --- a/src/components/UpgradesSidebar.tsx +++ b/src/components/UpgradesSidebar.tsx @@ -19,6 +19,7 @@ import { useGameStore } from "../store"; import type { BuyMode } from "../store/settingsStore"; import { useSettingsStore } from "../store/settingsStore"; import { D } from "../utils/decimal"; +import { GeneratorCpsBreakdown } from "./GeneratorCpsBreakdown"; import { ClickUpgradeCard } from "./upgrades/ClickUpgradeCard"; import { UpgradeCard } from "./upgrades/UpgradeCard"; @@ -29,18 +30,18 @@ interface TierConfig { } const TIER_CONFIG: readonly TierConfig[] = [ - { tier: "garage-lab", label: "🔬 Garage Lab", unlockStage: 0 }, - { tier: "startup", label: "🚀 Startup", unlockStage: 0 }, - { tier: "scale-up", label: "🏗️ Scale-Up", unlockStage: 2 }, - { tier: "mega-corp", label: "🏢 Mega Corp", unlockStage: 3 }, - { tier: "transcendence", label: "✨ Transcendence", unlockStage: 4 }, + { tier: "garage-lab", label: "\uD83D\uDD2C Garage Lab", unlockStage: 0 }, + { tier: "startup", label: "\uD83D\uDE80 Startup", unlockStage: 0 }, + { tier: "scale-up", label: "\uD83C\uDFD7\uFE0F Scale-Up", unlockStage: 2 }, + { tier: "mega-corp", label: "\uD83C\uDFE2 Mega Corp", unlockStage: 3 }, + { tier: "transcendence", label: "\u2728 Transcendence", unlockStage: 4 }, ]; const BUY_MODES: readonly { mode: BuyMode; label: string; shortcut: string }[] = [ - { mode: 1, label: "×1", shortcut: "1" }, - { mode: 10, label: "×10", shortcut: "2" }, - { mode: 100, label: "×100", shortcut: "3" }, + { mode: 1, label: "\u00d71", shortcut: "1" }, + { mode: 10, label: "\u00d710", shortcut: "2" }, + { mode: 100, label: "\u00d7100", shortcut: "3" }, { mode: "max", label: "Max", shortcut: "4" }, ]; @@ -81,7 +82,7 @@ function CategoryHeader({ transition: "transform 200ms ease", }} > - ▼ + \u25bc @@ -136,6 +137,7 @@ export function UpgradesSidebar() { "scale-up": true, "mega-corp": true, transcendence: true, + "production-breakdown": true, }, ); @@ -172,6 +174,11 @@ export function UpgradesSidebar() { const hasAnyUpgrades = visibleClickUpgrades.length > 0 || visibleTiers.length > 0; + // Count of owned generators for the breakdown header badge + const ownedGeneratorCount = UPGRADES.filter( + (u) => (upgradeOwned[u.id] ?? 0) > 0, + ).length; + return (