Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions src/components/GeneratorCpsBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { Badge, Group, Stack, Text } from "@mantine/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { UPGRADES } from "../data/upgrades";
import { computeAllGeneratorsCps } from "../engine/upgradeEngine";
import { useGameStore } from "../store";
import { formatNumber } from "../utils/formatNumber";

/** Number of 1-second buckets for the rolling click average. */
const CLICK_BUFFER_SIZE = 10;

/**
* Hook that tracks a rolling 10-second average of TD earned from clicks.
* Uses a circular buffer of 1-second buckets updated via Zustand subscription.
*/
function useRollingClickAverage(): number {
const bucketsRef = useRef<number[]>(new Array(CLICK_BUFFER_SIZE).fill(0));
const currentIndexRef = useRef(0);
const lastTickRef = useRef(Math.floor(Date.now() / 1000));
const [average, setAverage] = useState(0);

const advanceBuckets = useCallback(() => {
const now = Math.floor(Date.now() / 1000);
const elapsed = now - lastTickRef.current;
if (elapsed > 0) {
const steps = Math.min(elapsed, CLICK_BUFFER_SIZE);
for (let i = 0; i < steps; i++) {
currentIndexRef.current =
(currentIndexRef.current + 1) % CLICK_BUFFER_SIZE;
bucketsRef.current[currentIndexRef.current] = 0;
}
lastTickRef.current = now;
}
}, []);

// Subscribe to totalClicks changes to detect click events
useEffect(() => {
let prevClicks = useGameStore.getState().totalClicks;
let prevTd = useGameStore.getState().trainingData;

const unsub = useGameStore.subscribe((state) => {
const newClicks = state.totalClicks;
if (newClicks > prevClicks) {
advanceBuckets();
// Estimate TD earned from this click as the delta in trainingData
const tdDelta = state.trainingData.sub(prevTd).toNumber();
// Only count positive deltas that coincide with click increments
if (tdDelta > 0) {
bucketsRef.current[currentIndexRef.current] += tdDelta;
}
}
prevClicks = newClicks;
prevTd = state.trainingData;
});

return unsub;
}, [advanceBuckets]);

// Recalculate average every second
useEffect(() => {
const interval = setInterval(() => {
advanceBuckets();
const sum = bucketsRef.current.reduce((a, b) => a + b, 0);
setAverage(sum / CLICK_BUFFER_SIZE);
}, 1000);
return () => clearInterval(interval);
}, [advanceBuckets]);

return average;
}

/**
* 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);

const grandTotalCps = useMemo(
() => activeRows.reduce((sum, r) => sum + r.totalCps, 0),
[activeRows],
);

const clickAverage = useRollingClickAverage();

if (activeRows.length === 0) {
return (
<Text size="xs" c="dimmed" ff="monospace" ta="center" py="xs">
No generators owned yet.
</Text>
);
}

return (
<Stack gap={3}>
{/* Column headers */}
<Group
justify="space-between"
wrap="nowrap"
gap="xs"
pb={2}
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
>
<Text size="xs" c="dimmed" ff="monospace" fw={700} style={{ flex: 1 }}>
Generator
</Text>
<Text
size="xs"
c="dimmed"
ff="monospace"
fw={700}
style={{ flexShrink: 0, minWidth: 70, textAlign: "right" }}
>
CPS
</Text>
<Text
size="xs"
c="dimmed"
ff="monospace"
fw={700}
style={{ flexShrink: 0, minWidth: 42, textAlign: "right" }}
>
Share
</Text>
</Group>

{activeRows.map((row) => (
<Stack key={row.id} gap={0}>
<Group justify="space-between" wrap="nowrap" gap="xs" align="center">
{/* Icon + name + owned badge */}
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Text
size="xs"
ff="monospace"
style={{ flexShrink: 0, lineHeight: 1 }}
aria-hidden="true"
>
{row.icon}
</Text>
<Text
size="xs"
ff="monospace"
truncate
title={row.name}
style={{ flex: 1, minWidth: 0 }}
>
{row.name}
</Text>
<Badge
size="xs"
variant="light"
color="gray"
style={{ flexShrink: 0 }}
title={`${row.owned} owned`}
>
Γ—{row.owned}
</Badge>
</Group>

{/* Total CPS */}
<Text
size="xs"
ff="monospace"
c="green"
style={{ flexShrink: 0, minWidth: 70, textAlign: "right" }}
title={`${row.totalCps} TD/s`}
>
{formatNumber(row.totalCps)}/s
</Text>

{/* Percentage share */}
<Badge
size="xs"
variant="light"
color="cyan"
style={{ flexShrink: 0, minWidth: 42, textAlign: "center" }}
title={`${row.percentOfTotal.toFixed(2)}% of total CPS`}
>
{row.percentOfTotal.toFixed(1)}%
</Badge>
</Group>

{/* Next milestone hint */}
{row.nextMilestone && (
<Text
size="xs"
ff="monospace"
c="dimmed"
pl={20}
style={{ lineHeight: 1.3 }}
title={`${row.nextMilestone.label} bonus at ${row.nextMilestone.threshold} owned`}
>
Next Γ—{row.nextMilestone.multiplier} at{" "}
{row.nextMilestone.threshold} owned
</Text>
)}
</Stack>
))}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: After the generator rows, this component needs a summary footer row showing:

  1. Grand total TD/s (sum of all generators)
  2. Rolling 10-second average of TD earned from clicks

This is an explicit AC from Issue #105. The grand total can be derived from rows directly. The click average will need a small circular buffer β€” see the issue's technical notes for the suggested approach (10 Γ— 1-second buckets in ephemeral state).


{/* Summary footer */}
<Group
justify="space-between"
wrap="nowrap"
gap="xs"
pt={4}
style={{ borderTop: "1px solid var(--mantine-color-dark-4)" }}
>
<Text size="xs" ff="monospace" fw={700} c="teal" style={{ flex: 1 }}>
Total
</Text>
<Text
size="xs"
ff="monospace"
fw={700}
c="green"
style={{ flexShrink: 0, minWidth: 70, textAlign: "right" }}
title={`${grandTotalCps} TD/s from generators`}
>
{formatNumber(grandTotalCps)}/s
</Text>
<Badge
size="xs"
variant="light"
color="cyan"
style={{ flexShrink: 0, minWidth: 42, textAlign: "center" }}
>
100%
</Badge>
</Group>

{/* Rolling click average */}
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text size="xs" ff="monospace" c="dimmed" style={{ flex: 1 }}>
Click avg (10s)
</Text>
<Text
size="xs"
ff="monospace"
c="yellow"
style={{ flexShrink: 0, minWidth: 70, textAlign: "right" }}
title="Rolling 10-second average TD earned from clicks"
>
{formatNumber(clickAverage)}/s
</Text>
<div style={{ flexShrink: 0, minWidth: 42 }} />
</Group>
</Stack>
);
}
46 changes: 36 additions & 10 deletions src/components/UpgradesSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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" },
];

Expand Down Expand Up @@ -81,7 +82,7 @@ function CategoryHeader({
transition: "transform 200ms ease",
}}
>
β–Ό
\u25bc
</Text>
</Group>
</UnstyledButton>
Expand Down Expand Up @@ -136,6 +137,7 @@ export function UpgradesSidebar() {
"scale-up": true,
"mega-corp": true,
transcendence: true,
"production-breakdown": true,
},
);

Expand Down Expand Up @@ -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 (
<aside aria-label="Upgrades" className="sidebar-upgrades">
<Stack
Expand Down Expand Up @@ -212,7 +219,7 @@ export function UpgradesSidebar() {
{visibleClickUpgrades.length > 0 && (
<div>
<CategoryHeader
label="πŸ–±οΈ Click Boosters"
label="\uD83D\uDDB1\uFE0F Click Boosters"
count={visibleClickUpgrades.length}
open={openCategories["click-boosters"] ?? true}
onToggle={() => toggle("click-boosters")}
Expand Down Expand Up @@ -281,6 +288,25 @@ export function UpgradesSidebar() {
</div>
);
})}

{/* Production Breakdown section β€” live CPS share per generator */}
<div>
<CategoryHeader
label="\uD83D\uDCCA Production Breakdown"
count={ownedGeneratorCount}
open={openCategories["production-breakdown"] ?? true}
onToggle={() => toggle("production-breakdown")}
color="teal"
/>
<Collapse
in={openCategories["production-breakdown"] ?? true}
transitionDuration={200}
>
<div style={{ marginTop: 4, paddingBottom: 4 }}>
<GeneratorCpsBreakdown />
</div>
</Collapse>
</div>
</Stack>
</ScrollArea>
</Stack>
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { BonusesSidebar } from "./BonusesSidebar";
export { CrtOverlay } from "./CrtOverlay";
export { FloatingParticles } from "./FloatingParticles";
export { GameLayout } from "./GameLayout";
export { GeneratorCpsBreakdown } from "./GeneratorCpsBreakdown";
export { OfflineProgressModal } from "./OfflineProgressModal";
export { PetDisplay } from "./PetDisplay";
export { RebirthModal } from "./RebirthModal";
Expand Down
Loading
Loading