From f99a394b029eac4065e0e2223cc08eaac6528dbe Mon Sep 17 00:00:00 2001
From: Roland Chelwing
Date: Fri, 17 Apr 2026 13:35:15 +0200
Subject: [PATCH 1/9] =?UTF-8?q?feat:=20gameplay=20changes=20=E2=80=94=20co?=
=?UTF-8?q?st=20scaling,=20hacks=20prestige=20gate,=20endgame=20surge?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three gameplay changes:
1. Building cost multiplier 1.15 → 1.30 for all 12 buildings
Makes progression ~2x longer (intern: 15→20→25→33)
2. Hacks require "Hacker Mindset" prestige upgrade (5,000 rep)
Removes early-game power spike, makes hacks a mid-game reward
3. Escalating Surge when 9+ buildings mastered
- Production multiplier starts at 2x, +1x every 30 seconds
- Buffs spawn 2x faster with doubled duration
- Surge indicator in TopBar shows current multiplier
- Makes the final push for last 3 buildings feel like a crescendo
Also updates Help drawer with all mechanic changes.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/buffs/BuffSpawn.tsx | 15 +++++++++-----
src/components/help/HelpDrawer.tsx | 23 +++++++++++++++++++---
src/components/layout/MobileBottomNav.tsx | 5 ++++-
src/components/layout/StatsPanel.tsx | 4 ++--
src/components/layout/TopBar.tsx | 11 +++++++++++
src/config/gameConfig.ts | 5 +++++
src/data/buildings.ts | 24 +++++++++++------------
src/data/prestige.ts | 8 ++++++++
src/engine/production.ts | 13 ++++++++++++
src/store/gameStore.ts | 19 +++++++++++++++++-
src/store/selectors.ts | 15 +++++++++++++-
src/test/calculations.test.ts | 2 +-
src/test/selectors.test.ts | 1 +
src/types/game.ts | 1 +
14 files changed, 120 insertions(+), 26 deletions(-)
diff --git a/src/components/buffs/BuffSpawn.tsx b/src/components/buffs/BuffSpawn.tsx
index c67d220..9b27515 100644
--- a/src/components/buffs/BuffSpawn.tsx
+++ b/src/components/buffs/BuffSpawn.tsx
@@ -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;
@@ -22,10 +22,14 @@ export function BuffSpawnLayer() {
const [messages, setMessages] = useState([]);
const { items, removeItem } = useSpawnSystem({
- getInterval: () => ({
- min: GAME_CONFIG.buffs.minSpawnIntervalMs,
- max: GAME_CONFIG.buffs.maxSpawnIntervalMs,
- }),
+ getInterval: () => {
+ const surgeActive = selectIsSurgeActive(useGameStore.getState());
+ const divisor = surgeActive ? 2 : 1;
+ return {
+ min: GAME_CONFIG.buffs.minSpawnIntervalMs / divisor,
+ max: GAME_CONFIG.buffs.maxSpawnIntervalMs / divisor,
+ };
+ },
canSpawn: (current) => current.length === 0,
createItem: () => pickRandomBuff(),
getLifetime: () => SPAWN_DURATION,
@@ -50,6 +54,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;
state.addBuff({
id: `${item.data.id}_${Date.now()}`,
buffId: item.data.id,
diff --git a/src/components/help/HelpDrawer.tsx b/src/components/help/HelpDrawer.tsx
index 0e9839f..69897af 100644
--- a/src/components/help/HelpDrawer.tsx
+++ b/src/components/help/HelpDrawer.tsx
@@ -74,7 +74,7 @@ export function HelpDrawer({ open, onOpenChange }: Props) {
- Each building costs more as you buy more (1.15x per unit). Use Buy x1/x10/x100/Max to
+ Each building costs more as you buy more (1.3x per unit). Use Buy x1/x10/x100/Max to
bulk purchase.
@@ -125,8 +125,8 @@ export function HelpDrawer({ open, onOpenChange }: Props) {
- 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 Hacker Mindset prestige upgrade
+ (5,000 rep). Individual hacks unlock at LoC milestones. TD costs scale with your current production.
@@ -195,6 +195,23 @@ export function HelpDrawer({ open, onOpenChange }: Props) {
+
Once you've earned 1,000,000 total LoC in a run, you can{" "}
diff --git a/src/components/layout/MobileBottomNav.tsx b/src/components/layout/MobileBottomNav.tsx
index 6f03193..0eac8ed 100644
--- a/src/components/layout/MobileBottomNav.tsx
+++ b/src/components/layout/MobileBottomNav.tsx
@@ -14,6 +14,7 @@ interface Props {
export function MobileBottomNav({ onHelpClick }: Props) {
const [activeDrawer, setActiveDrawer] = useState(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) => {
@@ -30,7 +31,9 @@ export function MobileBottomNav({ onHelpClick }: Props) {
{/* Fixed bottom navigation bar */}
openDrawer("shop")} />
- openDrawer("hacks")} />
+ {hasHackAccess && (
+ openDrawer("hacks")} />
+ )}
openDrawer("stats")} />
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;
// Force re-render on tick
useGameStore((s) => s.resources.linesOfCode);
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx
index 4750e22..f9751c0 100644
--- a/src/components/layout/TopBar.tsx
+++ b/src/components/layout/TopBar.tsx
@@ -7,6 +7,7 @@ import {
selectLocPerSecond,
selectNetTechDebtPerSecond,
selectReputationOnPrestige,
+ selectSurgeMultiplier,
selectTechDebtMultiplier,
} from "../../store/selectors";
import { formatNumber, formatRate } from "../../utils/formatNumber";
@@ -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);
@@ -67,6 +69,15 @@ export function TopBar({ onPrestigeClick, onHelpClick }: Props) {
{/* Mobile: inline LoC/s */}
{formatRate(locPerSec)}
+ {/* Surge indicator */}
+ {surgeMultiplier > 1 && (
+
+
+ ⚡ SURGE {surgeMultiplier}x
+
+
+ )}
+
{/* Desktop: production stats */}
diff --git a/src/config/gameConfig.ts b/src/config/gameConfig.ts
index 286ce39..6f14446 100644
--- a/src/config/gameConfig.ts
+++ b/src/config/gameConfig.ts
@@ -49,4 +49,9 @@ export const GAME_CONFIG = {
autosave: {
intervalMs: 30_000,
},
+ surge: {
+ masteryThreshold: 9,
+ startMultiplier: 2,
+ intervalSec: 30,
+ },
} as const;
diff --git a/src/data/buildings.ts b/src/data/buildings.ts
index 0c976a7..22d6362 100644
--- a/src/data/buildings.ts
+++ b/src/data/buildings.ts
@@ -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: "☕",
@@ -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: "💻",
@@ -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: "🧑💻",
@@ -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: "📊",
@@ -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: "🔧",
@@ -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: "🤖",
@@ -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: "📐",
@@ -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: "🖥️",
@@ -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: "☁️",
@@ -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: "🌐",
@@ -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: "⚛️",
@@ -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: "🌌",
diff --git a/src/data/prestige.ts b/src/data/prestige.ts
index 2e1f127..49e61c4 100644
--- a/src/data/prestige.ts
+++ b/src/data/prestige.ts
@@ -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",
diff --git a/src/engine/production.ts b/src/engine/production.ts
index 6b6473c..dbd21dc 100644
--- a/src/engine/production.ts
+++ b/src/engine/production.ts
@@ -19,6 +19,7 @@ export interface ProductionResult {
techDebtPerSec: number;
clickValue: number;
tdMultiplier: number;
+ surgeMultiplier: number;
isRefactoring: boolean;
buildingProductions: Map
;
}
@@ -90,6 +91,17 @@ export function computeAllProduction(state: GameState): ProductionResult {
}
}
+ // Apply surge multiplier if active
+ let surgeMultiplier = 1;
+ if (state.surgeStartedAt) {
+ const surgeElapsed = (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);
@@ -106,6 +118,7 @@ export function computeAllProduction(state: GameState): ProductionResult {
techDebtPerSec,
clickValue,
tdMultiplier,
+ surgeMultiplier,
isRefactoring,
buildingProductions,
};
diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts
index 3279ef7..8897bfc 100644
--- a/src/store/gameStore.ts
+++ b/src/store/gameStore.ts
@@ -15,7 +15,13 @@ import {
loadFromStorage,
saveToStorage,
} from "../utils/saveManager";
-import { selectClickValue, selectCostReduction, selectLocPerSecond, selectReputationOnPrestige } from "./selectors";
+import {
+ selectClickValue,
+ selectCostReduction,
+ selectIsSurgeActive,
+ selectLocPerSecond,
+ selectReputationOnPrestige,
+} from "./selectors";
function getStartingLoC(prestigeUpgrades: string[]): number {
let loc = 0;
@@ -72,6 +78,7 @@ function createInitialState(
activeBuffs: [],
hackCooldowns: {},
refactoringUntil: 0,
+ surgeStartedAt: null,
prestige: prestigeState,
settings: {
autoSaveEnabled: true,
@@ -136,6 +143,15 @@ export const useGameStore = create()(
};
});
+ // Manage surge state
+ const surgeActive = selectIsSurgeActive(state);
+ let surgeStartedAt = state.surgeStartedAt;
+ if (surgeActive && !surgeStartedAt) {
+ surgeStartedAt = Date.now();
+ } else if (!surgeActive) {
+ surgeStartedAt = null;
+ }
+
set({
resources: {
...state.resources,
@@ -146,6 +162,7 @@ export const useGameStore = create()(
peakTechDebt: Math.max(state.resources.peakTechDebt ?? 0, newTD),
},
buildings: updatedBuildings,
+ surgeStartedAt,
stats: {
...state.stats,
totalTimePlayed: state.stats.totalTimePlayed + deltaSec,
diff --git a/src/store/selectors.ts b/src/store/selectors.ts
index ec8674e..f8138b5 100644
--- a/src/store/selectors.ts
+++ b/src/store/selectors.ts
@@ -408,7 +408,20 @@ export function selectTotalBuildings(state: GameState): number {
// === Win Condition ===
+export function selectMasteredCount(state: GameState): number {
+ return BUILDINGS.filter((def) => selectBuildingMastery(state, def.id)).length;
+}
+
+export function selectIsSurgeActive(state: GameState): boolean {
+ return selectMasteredCount(state) >= GAME_CONFIG.surge.masteryThreshold;
+}
+
+export function selectSurgeMultiplier(state: GameState): number {
+ if (!state.surgeStartedAt) return 1;
+ const elapsed = (Date.now() - state.surgeStartedAt) / 1000;
+ return GAME_CONFIG.surge.startMultiplier + Math.floor(elapsed / GAME_CONFIG.surge.intervalSec);
+}
+
export function selectHasWon(state: GameState): boolean {
- // Win = every building has mastery (500 count + all its upgrades)
return BUILDINGS.every((def) => selectBuildingMastery(state, def.id));
}
diff --git a/src/test/calculations.test.ts b/src/test/calculations.test.ts
index 1c5ccf0..a69d49c 100644
--- a/src/test/calculations.test.ts
+++ b/src/test/calculations.test.ts
@@ -11,7 +11,7 @@ describe("calculateBuildingCost", () => {
it("scales cost with owned count", () => {
const cost1 = calculateBuildingCost(intern, 1);
- expect(cost1).toBe(Math.floor(15 * 1.15));
+ expect(cost1).toBe(Math.floor(15 * intern.costMultiplier));
});
it("increases exponentially", () => {
diff --git a/src/test/selectors.test.ts b/src/test/selectors.test.ts
index 24b4796..d3835c4 100644
--- a/src/test/selectors.test.ts
+++ b/src/test/selectors.test.ts
@@ -48,6 +48,7 @@ function createTestState(overrides: Partial = {}): GameState {
startedAt: Date.now(),
},
refactoringUntil: 0,
+ surgeStartedAt: null,
lastSaveTimestamp: Date.now(),
lastTickTimestamp: Date.now(),
gameVersion: "1.0.0",
diff --git a/src/types/game.ts b/src/types/game.ts
index 2396edc..d70035e 100644
--- a/src/types/game.ts
+++ b/src/types/game.ts
@@ -133,6 +133,7 @@ export interface GameState {
settings: GameSettings;
stats: GameStats;
refactoringUntil: number;
+ surgeStartedAt: number | null;
lastSaveTimestamp: number;
lastTickTimestamp: number;
gameVersion: string;
From c59effc47edb02e27e148322ad7766b8a91048c7 Mon Sep 17 00:00:00 2001
From: Roland Chelwing
Date: Fri, 17 Apr 2026 13:36:32 +0200
Subject: [PATCH 2/9] fix: add compact refactor button to mobile TopBar
The refactor button was only in the desktop TD section (hidden on
mobile). Now the mobile compact TD indicator includes a small
Refactor button that shows a countdown timer during refactoring.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/layout/TopBar.tsx | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx
index f9751c0..b842442 100644
--- a/src/components/layout/TopBar.tsx
+++ b/src/components/layout/TopBar.tsx
@@ -101,10 +101,22 @@ export function TopBar({ onPrestigeClick, onHelpClick }: Props) {
{/* Tech Debt indicator */}
{(hasTD || netTdPerSec !== 0) && (
<>
- {/* Mobile: compact TD */}
+ {/* Mobile: compact TD + refactor */}
{formatNumber(td)}
{penaltyPercent > 0 && -{penaltyPercent}% }
+ {isRefactoring ? (
+ {refactorRemaining}s
+ ) : (
+ 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
+
+ )}
{/* Desktop: full TD section */}
From ed22003e53d2ebd9f1f3536c728512818adeaa02 Mon Sep 17 00:00:00 2001
From: Roland Chelwing
Date: Fri, 17 Apr 2026 13:53:59 +0200
Subject: [PATCH 3/9] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?=
=?UTF-8?q?hack=20gate,=20surge=20clamp,=20tests,=20buff=20trigger?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Gate activateHack behind hack_access prestige upgrade
- Clamp surge elapsed to >= 0 (handles clock backwards)
- Reset surgeStartedAt to now on load (prevents offline surge growth)
- Add rescheduleTrigger to BuffSpawn for surge state changes
- Add 6 surge selector tests + 2 surge tick tests + hack gate test
- Update hack test to require hack_access
- Remove stale comment in calculations test
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/buffs/BuffSpawn.tsx | 34 +++++++++++---------
src/engine/production.ts | 2 +-
src/store/gameStore.ts | 2 ++
src/store/selectors.ts | 2 +-
src/test/calculations.test.ts | 2 +-
src/test/gameStore.test.ts | 47 +++++++++++++++++++++++++++-
src/test/selectors.test.ts | 50 ++++++++++++++++++++++++++++++
7 files changed, 120 insertions(+), 19 deletions(-)
diff --git a/src/components/buffs/BuffSpawn.tsx b/src/components/buffs/BuffSpawn.tsx
index 9b27515..87691bf 100644
--- a/src/components/buffs/BuffSpawn.tsx
+++ b/src/components/buffs/BuffSpawn.tsx
@@ -20,22 +20,26 @@ let msgKey = 0;
export function BuffSpawnLayer() {
const [messages, setMessages] = useState([]);
-
- const { items, removeItem } = useSpawnSystem({
- getInterval: () => {
- const surgeActive = selectIsSurgeActive(useGameStore.getState());
- const divisor = surgeActive ? 2 : 1;
- return {
- min: GAME_CONFIG.buffs.minSpawnIntervalMs / divisor,
- max: GAME_CONFIG.buffs.maxSpawnIntervalMs / divisor,
- };
+ const surgeStartedAt = useGameStore((s) => s.surgeStartedAt);
+
+ const { items, removeItem } = useSpawnSystem(
+ {
+ getInterval: () => {
+ const surgeActive = selectIsSurgeActive(useGameStore.getState());
+ const divisor = surgeActive ? 2 : 1;
+ return {
+ min: GAME_CONFIG.buffs.minSpawnIntervalMs / divisor,
+ max: GAME_CONFIG.buffs.maxSpawnIntervalMs / divisor,
+ };
+ },
+ canSpawn: (current) => current.length === 0,
+ createItem: () => pickRandomBuff(),
+ getLifetime: () => SPAWN_DURATION,
+ paddingTop: 120,
+ paddingBottom: 80,
},
- canSpawn: (current) => current.length === 0,
- createItem: () => pickRandomBuff(),
- getLifetime: () => SPAWN_DURATION,
- paddingTop: 120,
- paddingBottom: 80,
- });
+ surgeStartedAt,
+ );
const handleClick = useCallback(
(item: SpawnedItem) => {
diff --git a/src/engine/production.ts b/src/engine/production.ts
index dbd21dc..6b4f406 100644
--- a/src/engine/production.ts
+++ b/src/engine/production.ts
@@ -94,7 +94,7 @@ export function computeAllProduction(state: GameState): ProductionResult {
// Apply surge multiplier if active
let surgeMultiplier = 1;
if (state.surgeStartedAt) {
- const surgeElapsed = (now - state.surgeStartedAt) / 1000;
+ 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) {
diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts
index 8897bfc..5f80bf2 100644
--- a/src/store/gameStore.ts
+++ b/src/store/gameStore.ts
@@ -313,6 +313,7 @@ export const useGameStore = create()(
(id) => !id.startsWith("pm_") && id !== "auto_pm_meetings",
),
refactoringUntil: saved.refactoringUntil ?? 0,
+ surgeStartedAt: saved.surgeStartedAt ? now : null,
prestige: {
...saved.prestige,
lifetimeLoCEarned: saved.prestige.lifetimeLoCEarned ?? 0,
@@ -375,6 +376,7 @@ export const useGameStore = create()(
activateHack: (hackId: string) => {
const state = get();
+ if (!state.prestige.prestigeUpgrades.includes("hack_access")) return false;
const hack = HACKS.find((h) => h.id === hackId);
if (!hack) return false;
diff --git a/src/store/selectors.ts b/src/store/selectors.ts
index f8138b5..4054b97 100644
--- a/src/store/selectors.ts
+++ b/src/store/selectors.ts
@@ -418,7 +418,7 @@ export function selectIsSurgeActive(state: GameState): boolean {
export function selectSurgeMultiplier(state: GameState): number {
if (!state.surgeStartedAt) return 1;
- const elapsed = (Date.now() - state.surgeStartedAt) / 1000;
+ const elapsed = Math.max(0, (Date.now() - state.surgeStartedAt) / 1000);
return GAME_CONFIG.surge.startMultiplier + Math.floor(elapsed / GAME_CONFIG.surge.intervalSec);
}
diff --git a/src/test/calculations.test.ts b/src/test/calculations.test.ts
index a69d49c..535163c 100644
--- a/src/test/calculations.test.ts
+++ b/src/test/calculations.test.ts
@@ -3,7 +3,7 @@ import { BUILDINGS } from "../data/buildings";
import { calculateBuildingCost, calculateMaxAffordable, MAX_BUILDING_COUNT } from "../utils/calculations";
describe("calculateBuildingCost", () => {
- const intern = BUILDINGS[0]; // baseCost: 15, costMultiplier: 1.15
+ const intern = BUILDINGS[0];
it("returns base cost for first unit", () => {
expect(calculateBuildingCost(intern, 0)).toBe(15);
diff --git a/src/test/gameStore.test.ts b/src/test/gameStore.test.ts
index 7df54e6..4f55819 100644
--- a/src/test/gameStore.test.ts
+++ b/src/test/gameStore.test.ts
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
+import { getStandardUpgradeIds } from "../data/standardUpgrades";
import { useGameStore } from "../store/gameStore";
function resetStore() {
@@ -159,7 +160,7 @@ describe("gameStore", () => {
});
describe("activateHack", () => {
- it("adds tech debt and sets cooldown", () => {
+ it("fails without hack_access prestige upgrade", () => {
useGameStore.setState({
resources: {
...useGameStore.getState().resources,
@@ -168,6 +169,22 @@ describe("gameStore", () => {
},
buildings: useGameStore.getState().buildings.map((b) => (b.id === "intern" ? { ...b, count: 50 } : b)),
});
+ expect(useGameStore.getState().activateHack("spaghetti_sprint")).toBe(false);
+ });
+
+ it("adds tech debt and sets cooldown with hack_access", () => {
+ useGameStore.setState({
+ resources: {
+ ...useGameStore.getState().resources,
+ linesOfCode: 100_000,
+ totalLoCEarned: 100_000,
+ },
+ buildings: useGameStore.getState().buildings.map((b) => (b.id === "intern" ? { ...b, count: 50 } : b)),
+ prestige: {
+ ...useGameStore.getState().prestige,
+ prestigeUpgrades: ["hack_access"],
+ },
+ });
const tdBefore = useGameStore.getState().resources.techDebt;
const result = useGameStore.getState().activateHack("spaghetti_sprint");
expect(result).toBe(true);
@@ -181,12 +198,40 @@ describe("gameStore", () => {
...useGameStore.getState().resources,
totalLoCEarned: 100_000,
},
+ prestige: {
+ ...useGameStore.getState().prestige,
+ prestigeUpgrades: ["hack_access"],
+ },
hackCooldowns: { spaghetti_sprint: Date.now() + 60_000 },
});
expect(useGameStore.getState().activateHack("spaghetti_sprint")).toBe(false);
});
});
+ describe("surge state", () => {
+ it("sets surgeStartedAt when 9 buildings mastered", () => {
+ // Master 9 buildings
+ const upgrades: string[] = [];
+ const buildings = useGameStore.getState().buildings.map((b, i) => {
+ if (i < 9) {
+ upgrades.push(...getStandardUpgradeIds(b.id));
+ return { ...b, count: 500 };
+ }
+ return b;
+ });
+ useGameStore.setState({ buildings, purchasedUpgrades: upgrades });
+
+ expect(useGameStore.getState().surgeStartedAt).toBeNull();
+ useGameStore.getState().tick(50);
+ expect(useGameStore.getState().surgeStartedAt).not.toBeNull();
+ });
+
+ it("does not set surgeStartedAt below threshold", () => {
+ useGameStore.getState().tick(50);
+ expect(useGameStore.getState().surgeStartedAt).toBeNull();
+ });
+ });
+
describe("shipProduct", () => {
it("resets buildings and LoC", () => {
useGameStore.setState({
diff --git a/src/test/selectors.test.ts b/src/test/selectors.test.ts
index d3835c4..58ac37a 100644
--- a/src/test/selectors.test.ts
+++ b/src/test/selectors.test.ts
@@ -7,10 +7,13 @@ import {
selectBuildingProduction,
selectClickValue,
selectHasWon,
+ selectIsSurgeActive,
selectLocPerSecond,
+ selectMasteredCount,
selectNetTechDebtPerSecond,
selectPrestigeMultiplier,
selectRawLocPerSecond,
+ selectSurgeMultiplier,
selectTechDebtMultiplier,
selectTotalBuildings,
} from "../store/selectors";
@@ -312,3 +315,50 @@ describe("selectTotalBuildings", () => {
expect(selectTotalBuildings(state)).toBe(8);
});
});
+
+describe("surge selectors", () => {
+ function createMasteredState(masteredCount: number) {
+ const counts: Record = {};
+ const upgrades: string[] = [];
+ for (let i = 0; i < masteredCount && i < BUILDINGS.length; i++) {
+ counts[BUILDINGS[i].id] = 500;
+ upgrades.push(...getStandardUpgradeIds(BUILDINGS[i].id));
+ }
+ let state = withBuildings(createTestState(), counts);
+ state = { ...state, purchasedUpgrades: upgrades };
+ return state;
+ }
+
+ it("selectMasteredCount returns correct count", () => {
+ expect(selectMasteredCount(createMasteredState(0))).toBe(0);
+ expect(selectMasteredCount(createMasteredState(3))).toBe(3);
+ expect(selectMasteredCount(createMasteredState(9))).toBe(9);
+ });
+
+ it("selectIsSurgeActive is false below threshold", () => {
+ expect(selectIsSurgeActive(createMasteredState(8))).toBe(false);
+ });
+
+ it("selectIsSurgeActive is true at threshold", () => {
+ expect(selectIsSurgeActive(createMasteredState(9))).toBe(true);
+ });
+
+ it("selectSurgeMultiplier returns 1 when no surge", () => {
+ expect(selectSurgeMultiplier(createTestState())).toBe(1);
+ });
+
+ it("selectSurgeMultiplier grows over time", () => {
+ const now = Date.now();
+ const state = { ...createTestState(), surgeStartedAt: now - 65_000 };
+ const mult = selectSurgeMultiplier(state);
+ // 65s elapsed: startMultiplier(2) + floor(65/30) = 2 + 2 = 4
+ expect(mult).toBe(4);
+ });
+
+ it("selectSurgeMultiplier handles clock backwards gracefully", () => {
+ const state = { ...createTestState(), surgeStartedAt: Date.now() + 10_000 };
+ const mult = selectSurgeMultiplier(state);
+ // Negative elapsed clamped to 0: startMultiplier(2) + floor(0/30) = 2
+ expect(mult).toBe(2);
+ });
+});
From d5378e334f19bb0dc748ac423093f93a02500015 Mon Sep 17 00:00:00 2001
From: Roland Chelwing
Date: Fri, 17 Apr 2026 14:02:08 +0200
Subject: [PATCH 4/9] fix: BuildingCard TD display now reflects TD reduction
upgrades
The card was computing TD from raw baseProduction * techDebtRatio *
buildingMult, ignoring purchased TD reduction upgrades. Now applies
computeTdReduction() so the displayed rate matches the actual net
TD rate (e.g. "Notebook Best Practices" -40% is reflected).
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/shop/BuildingCard.tsx | 5 ++++-
src/engine/techDebt.ts | 2 +-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/components/shop/BuildingCard.tsx b/src/components/shop/BuildingCard.tsx
index 51287a9..7c72677 100644
--- a/src/components/shop/BuildingCard.tsx
+++ b/src/components/shop/BuildingCard.tsx
@@ -1,3 +1,4 @@
+import { computeTdReduction } from "../../engine/techDebt";
import { useGameStore } from "../../store/gameStore";
import {
selectBuildingMastery,
@@ -25,7 +26,9 @@ 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 purchasedSet = new Set(state.purchasedUpgrades);
+ const tdReductionMult = building.techDebtRatio > 0 ? computeTdReduction(building.id, purchasedSet) : 1;
+ const eachTD = building.baseProduction * building.techDebtRatio * buildingMult * tdReductionMult;
const isMastered = selectBuildingMastery(state, building.id);
const isMaxCount = owned >= MAX_BUILDING_COUNT;
diff --git a/src/engine/techDebt.ts b/src/engine/techDebt.ts
index 96f6f0c..559c833 100644
--- a/src/engine/techDebt.ts
+++ b/src/engine/techDebt.ts
@@ -83,7 +83,7 @@ export function computeTechDebtStatus(
// === Internal helpers ===
-function computeTdReduction(buildingId: string, purchasedSet: Set): number {
+export function computeTdReduction(buildingId: string, purchasedSet: Set): number {
let reduction = 1;
for (const up of TD_REDUCTION_UPGRADES.get(buildingId) ?? []) {
if (purchasedSet.has(up.id)) {
From ce03c99602daacbed9c805b6829276dd7011203c Mon Sep 17 00:00:00 2001
From: Roland Chelwing
Date: Fri, 17 Apr 2026 14:05:10 +0200
Subject: [PATCH 5/9] fix: remove redundant hideHacks check in StatsPanel JSX
showHacks already includes !hideHacks in its computation.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/layout/StatsPanel.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/layout/StatsPanel.tsx b/src/components/layout/StatsPanel.tsx
index 64cb231..6e2242e 100644
--- a/src/components/layout/StatsPanel.tsx
+++ b/src/components/layout/StatsPanel.tsx
@@ -64,7 +64,7 @@ export function StatsPanel({ hideHacks = false }: StatsPanelProps) {
{/* Hacks - below stats (hidden in mobile Stats drawer since Hacks has its own tab) */}
- {showHacks && !hideHacks && (
+ {showHacks && (
Hacks
From 6ac3209d1c7ea4bf2e41ff17b061fb6f667b0740 Mon Sep 17 00:00:00 2001
From: Roland Chelwing
Date: Fri, 17 Apr 2026 14:11:35 +0200
Subject: [PATCH 6/9] perf: use memoized getPurchasedSet instead of new Set in
BuildingCard
Export getPurchasedSet from selectors and use it in BuildingCard
instead of creating a new Set(purchasedUpgrades) on every render.
The memoized version caches by array reference.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/shop/BuildingCard.tsx | 4 ++--
src/store/selectors.ts | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/components/shop/BuildingCard.tsx b/src/components/shop/BuildingCard.tsx
index 7c72677..f98d08c 100644
--- a/src/components/shop/BuildingCard.tsx
+++ b/src/components/shop/BuildingCard.tsx
@@ -1,6 +1,7 @@
import { computeTdReduction } from "../../engine/techDebt";
import { useGameStore } from "../../store/gameStore";
import {
+ getPurchasedSet,
selectBuildingMastery,
selectBuildingMultiplier,
selectBuildingProduction,
@@ -26,8 +27,7 @@ export function BuildingCard({ building, buyQuantity }: Props) {
const totalProduction = selectBuildingProduction(state, building.id);
const buildingMult = selectBuildingMultiplier(state, building.id);
const eachLoC = building.baseProduction * buildingMult;
- const purchasedSet = new Set(state.purchasedUpgrades);
- const tdReductionMult = building.techDebtRatio > 0 ? computeTdReduction(building.id, purchasedSet) : 1;
+ const tdReductionMult = building.techDebtRatio > 0 ? computeTdReduction(building.id, getPurchasedSet(state)) : 1;
const eachTD = building.baseProduction * building.techDebtRatio * buildingMult * tdReductionMult;
const isMastered = selectBuildingMastery(state, building.id);
const isMaxCount = owned >= MAX_BUILDING_COUNT;
diff --git a/src/store/selectors.ts b/src/store/selectors.ts
index 4054b97..029dfb9 100644
--- a/src/store/selectors.ts
+++ b/src/store/selectors.ts
@@ -18,7 +18,7 @@ import type { GameState } from "../types/game";
let _upgradeSetCache: { upgrades: string[]; set: Set } | null = null;
-function getPurchasedSet(state: GameState): Set {
+export function getPurchasedSet(state: GameState): Set {
if (_upgradeSetCache && _upgradeSetCache.upgrades === state.purchasedUpgrades) {
return _upgradeSetCache.set;
}
From 1fefe62c3c7b35f0ce7aa6372ebaa15cfaa60749 Mon Sep 17 00:00:00 2001
From: Roland Chelwing
Date: Fri, 17 Apr 2026 14:22:03 +0200
Subject: [PATCH 7/9] fix: apply cleaner bonus to TD display for negative-ratio
buildings
Cleaner buildings (Senior Dev, DevOps, etc.) now show the boosted
cleanup rate in the card, matching the engine's computeCleanerBonus.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/shop/BuildingCard.tsx | 10 +++++++---
src/engine/techDebt.ts | 2 +-
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/components/shop/BuildingCard.tsx b/src/components/shop/BuildingCard.tsx
index f98d08c..5fd5073 100644
--- a/src/components/shop/BuildingCard.tsx
+++ b/src/components/shop/BuildingCard.tsx
@@ -1,4 +1,4 @@
-import { computeTdReduction } from "../../engine/techDebt";
+import { computeCleanerBonus, computeTdReduction } from "../../engine/techDebt";
import { useGameStore } from "../../store/gameStore";
import {
getPurchasedSet,
@@ -27,8 +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 tdReductionMult = building.techDebtRatio > 0 ? computeTdReduction(building.id, getPurchasedSet(state)) : 1;
- const eachTD = building.baseProduction * building.techDebtRatio * buildingMult * tdReductionMult;
+ 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;
diff --git a/src/engine/techDebt.ts b/src/engine/techDebt.ts
index 559c833..9a7c384 100644
--- a/src/engine/techDebt.ts
+++ b/src/engine/techDebt.ts
@@ -93,7 +93,7 @@ export function computeTdReduction(buildingId: string, purchasedSet: Set
return reduction;
}
-function computeCleanerBonus(buildingId: string, techDebtRatio: number, purchasedSet: Set): number {
+export function computeCleanerBonus(buildingId: string, techDebtRatio: number, purchasedSet: Set): number {
if (techDebtRatio >= 0) return 1;
const standardIds = getStandardUpgradeIds(buildingId);
let boosts = 0;
From 417e181410987cddedda88247a08fed3b970ee83 Mon Sep 17 00:00:00 2001
From: Roland Chelwing
Date: Fri, 17 Apr 2026 14:31:08 +0200
Subject: [PATCH 8/9] fix: use nullish check for surgeStartedAt on load
Change truthy check to != null so a hypothetical timestamp of 0
isn't incorrectly treated as null.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/store/gameStore.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts
index 5f80bf2..9188259 100644
--- a/src/store/gameStore.ts
+++ b/src/store/gameStore.ts
@@ -313,7 +313,7 @@ export const useGameStore = create()(
(id) => !id.startsWith("pm_") && id !== "auto_pm_meetings",
),
refactoringUntil: saved.refactoringUntil ?? 0,
- surgeStartedAt: saved.surgeStartedAt ? now : null,
+ surgeStartedAt: saved.surgeStartedAt != null ? now : null,
prestige: {
...saved.prestige,
lifetimeLoCEarned: saved.prestige.lifetimeLoCEarned ?? 0,
From 7774161666eb917a64ae99e4656693a91cad7791 Mon Sep 17 00:00:00 2001
From: Roland Chelwing
Date: Fri, 17 Apr 2026 15:00:58 +0200
Subject: [PATCH 9/9] fix: update section header and restore TD threshold
mention in help
- Rename "Internal helpers" to "TD modifier helpers" since functions
are now exported
- Restore TD threshold requirement text in Hacks help section
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/help/HelpDrawer.tsx | 3 ++-
src/engine/techDebt.ts | 2 +-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/components/help/HelpDrawer.tsx b/src/components/help/HelpDrawer.tsx
index 69897af..9348a99 100644
--- a/src/components/help/HelpDrawer.tsx
+++ b/src/components/help/HelpDrawer.tsx
@@ -126,7 +126,8 @@ export function HelpDrawer({ open, onOpenChange }: Props) {
Temporary boosts that add Tech Debt. Requires the Hacker Mindset prestige upgrade
- (5,000 rep). Individual hacks unlock at LoC milestones. TD costs scale with your current production.
+ (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.
diff --git a/src/engine/techDebt.ts b/src/engine/techDebt.ts
index 9a7c384..9a5a9d6 100644
--- a/src/engine/techDebt.ts
+++ b/src/engine/techDebt.ts
@@ -81,7 +81,7 @@ export function computeTechDebtStatus(
};
}
-// === Internal helpers ===
+// === TD modifier helpers ===
export function computeTdReduction(buildingId: string, purchasedSet: Set): number {
let reduction = 1;