Skip to content
Merged
35 changes: 22 additions & 13 deletions src/components/buffs/BuffSpawn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { pickRandomBuff } from "../../data/buffs";
import type { SpawnedItem } from "../../hooks/useSpawnSystem";
import { useSpawnSystem } from "../../hooks/useSpawnSystem";
import { useGameStore } from "../../store/gameStore";
import { selectClickValue, selectLocPerSecond } from "../../store/selectors";
import { selectClickValue, selectIsSurgeActive, selectLocPerSecond } from "../../store/selectors";

const SPAWN_DURATION = GAME_CONFIG.buffs.spawnDurationMs;

Expand All @@ -20,18 +20,26 @@ let msgKey = 0;

export function BuffSpawnLayer() {
const [messages, setMessages] = useState<BuffMessageItem[]>([]);

const { items, removeItem } = useSpawnSystem<BuffDefinition>({
getInterval: () => ({
min: GAME_CONFIG.buffs.minSpawnIntervalMs,
max: GAME_CONFIG.buffs.maxSpawnIntervalMs,
}),
canSpawn: (current) => current.length === 0,
createItem: () => pickRandomBuff(),
getLifetime: () => SPAWN_DURATION,
paddingTop: 120,
paddingBottom: 80,
});
const surgeStartedAt = useGameStore((s) => s.surgeStartedAt);

const { items, removeItem } = useSpawnSystem<BuffDefinition>(
{
getInterval: () => {
const surgeActive = selectIsSurgeActive(useGameStore.getState());
const divisor = surgeActive ? 2 : 1;
return {
min: GAME_CONFIG.buffs.minSpawnIntervalMs / divisor,
max: GAME_CONFIG.buffs.maxSpawnIntervalMs / divisor,
};
Comment on lines +27 to +33

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

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

getInterval uses selectIsSurgeActive(...) (mastery-threshold check) to speed up buff spawns. Because selectIsSurgeActive can be true before surgeStartedAt is set (surge production hasn’t started yet), buff spawns can speed up slightly earlier than the actual surge. Consider keying this off useGameStore.getState().surgeStartedAt != null (or a dedicated “surge active” selector) so all surge effects start at the same moment.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Keeping as-is: the one-tick gap between selectIsSurgeActive becoming true and surgeStartedAt being set is 50ms — imperceptible for buff spawn timing.

},
canSpawn: (current) => current.length === 0,
createItem: () => pickRandomBuff(),
getLifetime: () => SPAWN_DURATION,
paddingTop: 120,
paddingBottom: 80,
},
surgeStartedAt,
);

const handleClick = useCallback(
(item: SpawnedItem<BuffDefinition>) => {
Expand All @@ -50,6 +58,7 @@ export function BuffSpawnLayer() {
if (result.productionMultiplier || result.clickMultiplier) {
let duration = result.duration ?? 30;
if (state.prestige.prestigeUpgrades.includes("buff_mastery")) duration *= 2;
if (selectIsSurgeActive(state)) duration *= 2;

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

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

Buff duration doubling is currently based on selectIsSurgeActive(state) (mastery threshold), which can become true before the surge has actually started (surgeStartedAt is set in the next tick). To keep behavior consistent with the production surge, consider checking state.surgeStartedAt != null (or a dedicated “surge active” selector) here instead.

Suggested change
if (selectIsSurgeActive(state)) duration *= 2;
if (state.surgeStartedAt != null) duration *= 2;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Keeping as-is: same as above — one-tick (50ms) delay between mastery threshold and surge start is imperceptible for buff duration.

state.addBuff({
id: `${item.data.id}_${Date.now()}`,
buffId: item.data.id,
Expand Down
24 changes: 21 additions & 3 deletions src/components/help/HelpDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function HelpDrawer({ open, onOpenChange }: Props) {
</li>
</ul>
<p className="mt-2">
Each building costs more as you buy more (1.15x per unit). Use <strong>Buy x1/x10/x100/Max</strong> to
Each building costs more as you buy more (1.3x per unit). Use <strong>Buy x1/x10/x100/Max</strong> to
bulk purchase.
</p>
</Section>
Expand Down Expand Up @@ -125,8 +125,9 @@ export function HelpDrawer({ open, onOpenChange }: Props) {

<Section title="Hacks" icon="🍝">
<p>
Temporary boosts that add Tech Debt. Unlock at LoC milestones. Requires your TD to be below a threshold
to activate. TD costs scale with your current production.
Temporary boosts that add Tech Debt. Requires the <strong>Hacker Mindset</strong> prestige upgrade
(5,000 rep). Individual hacks unlock at LoC milestones. Requires your TD to be below a threshold to
activate. TD costs scale with your current production.
</p>
<ul className="list-none space-y-1 mt-2">
<li>
Expand Down Expand Up @@ -195,6 +196,23 @@ export function HelpDrawer({ open, onOpenChange }: Props) {
</p>
</Section>

<Section title="Endgame Surge" icon="⚡">
<p>
When <strong>9 or more buildings</strong> achieve Mastery, an{" "}
<strong className="text-accent-gold">Escalating Surge</strong> activates:
</p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li>
Production multiplier starts at <strong>2x</strong> and increases by 1x every 30 seconds
</li>
<li>Buffs spawn twice as fast with doubled duration</li>
<li>The surge resets when you prestige</li>
</ul>
<p className="mt-2 text-accent-gold text-xs">
The final push — master the last 3 buildings with an ever-growing production boost!
</p>
</Section>

<Section title="Prestige -- Ship the Product" icon="📦">
<p>
Once you've earned <strong>1,000,000 total LoC</strong> in a run, you can{" "}
Expand Down
5 changes: 4 additions & 1 deletion src/components/layout/MobileBottomNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface Props {
export function MobileBottomNav({ onHelpClick }: Props) {
const [activeDrawer, setActiveDrawer] = useState<MobileTab | null>(null);
const resetGame = useGameStore((s) => s.resetGame);
const hasHackAccess = useGameStore((s) => s.prestige.prestigeUpgrades.includes("hack_access"));
const [confirmingRestart, setConfirmingRestart] = useState(false);

const openDrawer = (tab: MobileTab) => {
Expand All @@ -30,7 +31,9 @@ export function MobileBottomNav({ onHelpClick }: Props) {
{/* Fixed bottom navigation bar */}
<nav className="fixed bottom-0 inset-x-0 z-30 lg:hidden flex items-center justify-around bg-bg-editor-bar border-t border-white/5 px-2 py-1 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<NavButton icon="🏪" label="Shop" active={activeDrawer === "shop"} onClick={() => openDrawer("shop")} />
<NavButton icon="🍝" label="Hacks" active={activeDrawer === "hacks"} onClick={() => openDrawer("hacks")} />
{hasHackAccess && (
<NavButton icon="🍝" label="Hacks" active={activeDrawer === "hacks"} onClick={() => openDrawer("hacks")} />
)}
<NavButton icon="📊" label="Stats" active={activeDrawer === "stats"} onClick={() => openDrawer("stats")} />
<NavButton
icon="❓"
Expand Down
6 changes: 3 additions & 3 deletions src/components/layout/StatsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ export function StatsPanel({ hideHacks = false }: StatsPanelProps) {
const timePlayed = useGameStore((s) => s.stats.totalTimePlayed);
const timesShipped = useGameStore((s) => s.prestige.timesShipped);
const reputation = useGameStore((s) => s.prestige.totalReputationEarned);
const totalLoCEarned = useGameStore((s) => s.resources.totalLoCEarned);

const locPerSec = selectLocPerSecond(state);
const clickValue = selectClickValue(state);
const prestigeMult = selectPrestigeMultiplier(state);
const totalBuildings = selectTotalBuildings(state);

const showHacks = totalLoCEarned >= 1000;
const hasHackAccess = state.prestige.prestigeUpgrades.includes("hack_access");
const showHacks = hasHackAccess && !hideHacks;
Comment on lines +37 to +38

This comment was marked as resolved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in ed22003 — activateHack now checks for hack_access prestige upgrade before proceeding.

Comment on lines +37 to +38

This comment was marked as resolved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in ce03c99 — removed the redundant && !hideHacks from the JSX since showHacks already includes it.


// Force re-render on tick
useGameStore((s) => s.resources.linesOfCode);
Expand Down Expand Up @@ -64,7 +64,7 @@ export function StatsPanel({ hideHacks = false }: StatsPanelProps) {
</div>

{/* Hacks - below stats (hidden in mobile Stats drawer since Hacks has its own tab) */}
{showHacks && !hideHacks && (
{showHacks && (
<div className="shrink-0 border-t border-white/5 pt-3 mt-3">
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">Hacks</h3>
<HackPanel />
Expand Down
25 changes: 24 additions & 1 deletion src/components/layout/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
selectLocPerSecond,
selectNetTechDebtPerSecond,
selectReputationOnPrestige,
selectSurgeMultiplier,
selectTechDebtMultiplier,
} from "../../store/selectors";
import { formatNumber, formatRate } from "../../utils/formatNumber";
Expand All @@ -32,6 +33,7 @@ export function TopBar({ onPrestigeClick, onHelpClick }: Props) {
const prestigeUpgrades = useGameStore((s) => s.prestige.prestigeUpgrades);
const netTdPerSec = selectNetTechDebtPerSecond(state);
const debtMult = selectTechDebtMultiplier(state);
const surgeMultiplier = selectSurgeMultiplier(state);

const hasTD = td > 0;
const penaltyPercent = Math.round((1 - debtMult) * 100);
Expand Down Expand Up @@ -67,6 +69,15 @@ export function TopBar({ onPrestigeClick, onHelpClick }: Props) {
{/* Mobile: inline LoC/s */}
<span className="font-mono text-xs text-accent-cyan font-semibold lg:hidden">{formatRate(locPerSec)}</span>

{/* Surge indicator */}
{surgeMultiplier > 1 && (
<div className="flex items-center gap-1 pl-2 lg:pl-5 border-l border-white/10">
<span className="text-accent-gold font-bold text-xs lg:text-sm animate-pulse">
⚡ SURGE {surgeMultiplier}x
</span>
</div>
)}

{/* Desktop: production stats */}
<div className="hidden lg:flex flex-col gap-0.5">
<div className="flex items-center gap-1">
Expand All @@ -90,10 +101,22 @@ export function TopBar({ onPrestigeClick, onHelpClick }: Props) {
{/* Tech Debt indicator */}
{(hasTD || netTdPerSec !== 0) && (
<>
{/* Mobile: compact TD */}
{/* Mobile: compact TD + refactor */}
<div className="flex items-center gap-1.5 pl-2 border-l border-white/10 lg:hidden">
<span className="font-mono text-xs text-accent-pink font-semibold">{formatNumber(td)}</span>
{penaltyPercent > 0 && <span className="text-[10px] text-accent-pink">-{penaltyPercent}%</span>}
{isRefactoring ? (
<span className="text-[10px] text-accent-gold font-semibold animate-pulse">{refactorRemaining}s</span>
) : (
<button
type="button"
onClick={() => refactorDebt()}
disabled={td <= 0}
className="px-1.5 py-0.5 rounded text-[9px] font-semibold bg-accent-green/10 text-accent-green border border-accent-green/20 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
Refactor
</button>
)}
Comment on lines +104 to +119

This comment was marked as resolved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Acknowledged — the mobile Refactor button was added in a follow-up commit to this PR. PR description updated.

</div>

{/* Desktop: full TD section */}
Expand Down
9 changes: 8 additions & 1 deletion src/components/shop/BuildingCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { computeCleanerBonus, computeTdReduction } from "../../engine/techDebt";
import { useGameStore } from "../../store/gameStore";
import {
getPurchasedSet,
selectBuildingMastery,
selectBuildingMultiplier,
selectBuildingProduction,
Expand All @@ -25,7 +27,12 @@ export function BuildingCard({ building, buyQuantity }: Props) {
const totalProduction = selectBuildingProduction(state, building.id);
const buildingMult = selectBuildingMultiplier(state, building.id);
const eachLoC = building.baseProduction * buildingMult;
const eachTD = building.baseProduction * building.techDebtRatio * buildingMult;
const purchased = getPurchasedSet(state);
const tdModifier =
building.techDebtRatio > 0
? computeTdReduction(building.id, purchased)
: computeCleanerBonus(building.id, building.techDebtRatio, purchased);
const eachTD = building.baseProduction * building.techDebtRatio * buildingMult * tdModifier;
const isMastered = selectBuildingMastery(state, building.id);
const isMaxCount = owned >= MAX_BUILDING_COUNT;

Expand Down
5 changes: 5 additions & 0 deletions src/config/gameConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,9 @@ export const GAME_CONFIG = {
autosave: {
intervalMs: 30_000,
},
surge: {
masteryThreshold: 9,
startMultiplier: 2,
intervalSec: 30,
},
} as const;
24 changes: 12 additions & 12 deletions src/data/buildings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Intern",
description: "Writes FizzBuzz... eventually",
baseCost: 15,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 0.2,
unlockThreshold: 0,
icon: "☕",
Expand All @@ -17,7 +17,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Junior Developer",
description: "Knows JavaScript. Just JavaScript.",
baseCost: 400,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 8,
unlockThreshold: 200,
icon: "💻",
Expand All @@ -28,7 +28,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Senior Developer",
description: "Has opinions about tabs vs spaces",
baseCost: 8_000,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 100,
unlockThreshold: 4_000,
icon: "🧑‍💻",
Expand All @@ -39,7 +39,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Data Scientist",
description: "Turns coffee into Jupyter notebooks",
baseCost: 120_000,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 1_200,
unlockThreshold: 50_000,
icon: "📊",
Expand All @@ -50,7 +50,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "DevOps Engineer",
description: "Automates everything except their own job security",
baseCost: 2_000_000,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 15_000,
unlockThreshold: 800_000,
icon: "🔧",
Expand All @@ -61,7 +61,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "AI Coding Assistant",
description: "Hallucinates features into existence",
baseCost: 40_000_000,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 200_000,
unlockThreshold: 15_000_000,
icon: "🤖",
Expand All @@ -72,7 +72,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Tech Lead",
description: "Draws architecture diagrams for fun",
baseCost: 1_000_000_000,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 3_000_000,
unlockThreshold: 400_000_000,
icon: "📐",
Expand All @@ -83,7 +83,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Server Farm",
description: "404 sleep not found",
baseCost: 100_000_000_000,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 50_000_000,
unlockThreshold: 40_000_000_000,
icon: "🖥️",
Expand All @@ -94,7 +94,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Cloud Architect",
description: "Draws diagrams with more boxes than your actual servers",
baseCost: 10_000_000_000_000,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 1_000_000_000,
unlockThreshold: 4_000_000_000_000,
icon: "☁️",
Expand All @@ -105,7 +105,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Open Source Community",
description: "10,000 devs mass-producing code",
baseCost: 1_000_000_000_000_000,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 25_000_000_000,
unlockThreshold: 400_000_000_000_000,
icon: "🌐",
Expand All @@ -116,7 +116,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Quantum Computing Lab",
description: "Qubits entangled, code compiled in parallel universes",
baseCost: 1e18,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 500_000_000_000,
unlockThreshold: 4e17,
icon: "⚛️",
Expand All @@ -127,7 +127,7 @@ export const BUILDINGS: BuildingDefinition[] = [
name: "Galactic Dev Network",
description: "A civilization of coders spanning the Milky Way",
baseCost: 1e21,
costMultiplier: 1.15,
costMultiplier: 1.3,
baseProduction: 10_000_000_000_000,
unlockThreshold: 4e20,
icon: "🌌",
Expand Down
8 changes: 8 additions & 0 deletions src/data/prestige.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export const PRESTIGE_UPGRADES: PrestigeUpgradeDefinition[] = [
icon: "🤝",
effect: "free_senior",
},
{
id: "hack_access",
name: "Hacker Mindset",
description: "Unlock Hacks — temporary boosts that add Tech Debt",
cost: 5_000,
icon: "🍝",
effect: "hack_access",
},
{
id: "scaling_expert",
name: "Scaling Expert",
Expand Down
13 changes: 13 additions & 0 deletions src/engine/production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ProductionResult {
techDebtPerSec: number;
clickValue: number;
tdMultiplier: number;
surgeMultiplier: number;
isRefactoring: boolean;
buildingProductions: Map<string, number>;
}
Expand Down Expand Up @@ -90,6 +91,17 @@ export function computeAllProduction(state: GameState): ProductionResult {
}
}

// Apply surge multiplier if active
let surgeMultiplier = 1;
if (state.surgeStartedAt) {

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

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

computeAllProduction gates surge with if (state.surgeStartedAt). Since surgeStartedAt is number | null, a valid (but falsy) timestamp like 0 would incorrectly disable surge. Prefer state.surgeStartedAt != null (and mirror the same nullish check in selectors/tick) for consistency.

Suggested change
if (state.surgeStartedAt) {
if (state.surgeStartedAt != null) {

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Keeping as-is: same reasoning — surgeStartedAt is set via Date.now() or null. The value 0 is not a valid state.

const surgeElapsed = Math.max(0, (now - state.surgeStartedAt) / 1000);
surgeMultiplier = GAME_CONFIG.surge.startMultiplier + Math.floor(surgeElapsed / GAME_CONFIG.surge.intervalSec);
locPerSec *= surgeMultiplier;
for (const [id, prod] of buildingProductions) {
buildingProductions.set(id, prod * surgeMultiplier);
}
}

// Compute click value from unpaused locPerSec (CPS bonus stays during refactoring)
const clickValue = computeClickValue(state, purchasedSet, locPerSec, now);

Expand All @@ -106,6 +118,7 @@ export function computeAllProduction(state: GameState): ProductionResult {
techDebtPerSec,
clickValue,
tdMultiplier,
surgeMultiplier,
isRefactoring,
buildingProductions,
};
Expand Down
Loading
Loading