diff --git a/src/components/buffs/BuffSpawn.tsx b/src/components/buffs/BuffSpawn.tsx
index c67d220..87691bf 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;
@@ -20,18 +20,26 @@ let msgKey = 0;
export function BuffSpawnLayer() {
const [messages, setMessages] = useState([]);
-
- const { items, removeItem } = useSpawnSystem({
- 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(
+ {
+ 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,
+ },
+ surgeStartedAt,
+ );
const handleClick = useCallback(
(item: SpawnedItem) => {
@@ -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;
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..9348a99 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,9 @@ 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. Requires your TD to be below a threshold to
+ activate. TD costs scale with your current production.
@@ -195,6 +196,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);
@@ -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
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx
index 4750e22..b842442 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 */}
@@ -90,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 */}
diff --git a/src/components/shop/BuildingCard.tsx b/src/components/shop/BuildingCard.tsx
index 51287a9..5fd5073 100644
--- a/src/components/shop/BuildingCard.tsx
+++ b/src/components/shop/BuildingCard.tsx
@@ -1,5 +1,7 @@
+import { computeCleanerBonus, computeTdReduction } from "../../engine/techDebt";
import { useGameStore } from "../../store/gameStore";
import {
+ getPurchasedSet,
selectBuildingMastery,
selectBuildingMultiplier,
selectBuildingProduction,
@@ -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;
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..6b4f406 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 = 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);
@@ -106,6 +118,7 @@ export function computeAllProduction(state: GameState): ProductionResult {
techDebtPerSec,
clickValue,
tdMultiplier,
+ surgeMultiplier,
isRefactoring,
buildingProductions,
};
diff --git a/src/engine/techDebt.ts b/src/engine/techDebt.ts
index 96f6f0c..9a5a9d6 100644
--- a/src/engine/techDebt.ts
+++ b/src/engine/techDebt.ts
@@ -81,9 +81,9 @@ export function computeTechDebtStatus(
};
}
-// === Internal helpers ===
+// === TD modifier 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)) {
@@ -93,7 +93,7 @@ function computeTdReduction(buildingId: string, purchasedSet: Set): numb
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;
diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts
index 3279ef7..9188259 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,
@@ -296,6 +313,7 @@ export const useGameStore = create()(
(id) => !id.startsWith("pm_") && id !== "auto_pm_meetings",
),
refactoringUntil: saved.refactoringUntil ?? 0,
+ surgeStartedAt: saved.surgeStartedAt != null ? now : null,
prestige: {
...saved.prestige,
lifetimeLoCEarned: saved.prestige.lifetimeLoCEarned ?? 0,
@@ -358,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 ec8674e..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;
}
@@ -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 = Math.max(0, (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..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);
@@ -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/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 24b4796..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";
@@ -48,6 +51,7 @@ function createTestState(overrides: Partial = {}): GameState {
startedAt: Date.now(),
},
refactoringUntil: 0,
+ surgeStartedAt: null,
lastSaveTimestamp: Date.now(),
lastTickTimestamp: Date.now(),
gameVersion: "1.0.0",
@@ -311,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);
+ });
+});
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;