diff --git a/package-lock.json b/package-lock.json
index 63ee607..344fd91 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@mantine/core": "8.3.15",
"@mantine/hooks": "8.3.15",
"@mantine/notifications": "8.3.15",
+ "break_infinity.js": "^2.2.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "5.0.11"
@@ -2029,6 +2030,15 @@
"require-from-string": "^2.0.2"
}
},
+ "node_modules/break_infinity.js": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/break_infinity.js/-/break_infinity.js-2.2.0.tgz",
+ "integrity": "sha512-Li+150FfqGDj4gcJrgEJatFAR0OmfZyZLBGFKSHgjWKqKgWRm7GA+OmSSHwDFqkdOOjoY+R8X/jyDX/9MQEjmA==",
+ "license": "MIT",
+ "dependencies": {
+ "pad-end": "^1.0.2"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -2605,6 +2615,12 @@
],
"license": "MIT"
},
+ "node_modules/pad-end": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/pad-end/-/pad-end-1.0.2.tgz",
+ "integrity": "sha512-pHkQejQ2oo08iXEDDFYxUK1xPe8L5fpbLSpkKk+ytimW70S8golMYFP9nAPTLXW6DTt+bF5QLcbswx5imMszHg==",
+ "license": "MIT"
+ },
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
diff --git a/package.json b/package.json
index 75fd20b..cc5c223 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"@mantine/core": "8.3.15",
"@mantine/hooks": "8.3.15",
"@mantine/notifications": "8.3.15",
+ "break_infinity.js": "^2.2.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "5.0.11"
diff --git a/src/components/PetDisplay.tsx b/src/components/PetDisplay.tsx
index cf67b9a..98bb42e 100644
--- a/src/components/PetDisplay.tsx
+++ b/src/components/PetDisplay.tsx
@@ -213,7 +213,10 @@ export function PetDisplay() {
}}
/>
)}
-
+
@@ -316,7 +319,7 @@ export function PetDisplay() {
performRebirth(selectedSpecies, challengeId);
setRebirthModalOpen(false);
}}
- totalTdEarned={totalTdEarned}
+ totalTdEarned={totalTdEarned.toNumber()}
currentBalance={prestigeTokenBalance}
nextSpecies={nextSpecies}
currentSpecies={currentSpecies}
@@ -326,7 +329,7 @@ export function PetDisplay() {
activeChallengeId={activeChallengeId}
totalClicks={totalClicks}
evolutionStage={evolutionStage}
- peakTdPerSecond={peakTdPerSecond}
+ peakTdPerSecond={peakTdPerSecond.toNumber()}
runStart={runStart}
/>
setShopOpen(false)} />
diff --git a/src/components/StatsBar.tsx b/src/components/StatsBar.tsx
index 579ead1..06904e1 100644
--- a/src/components/StatsBar.tsx
+++ b/src/components/StatsBar.tsx
@@ -16,6 +16,7 @@ import {
import { useInterpolatedTd } from "../hooks/useInterpolatedTd";
import { useGameStore } from "../store";
import { useSettingsStore } from "../store/settingsStore";
+import { D, type Decimal } from "../utils/decimal";
import { formatNumber, formatNumberFull } from "../utils/formatNumber";
const RATE_BOOST_DURATION_MS = 3000;
@@ -45,7 +46,8 @@ export function StatsBar() {
idleBoost * speciesBonus.autoGen,
boosterMultiplier,
);
- const tdPerSecond = activeChallengeId === "click-only" ? 0 : rawTdPerSecond;
+ const tdPerSecond =
+ activeChallengeId === "click-only" ? D(0) : rawTdPerSecond;
const clickMastery = getClickMasteryBonus(ep["click-mastery"] ?? 0);
const effectiveClickPower = computeClickPower(
{ clickUpgradesPurchased, comboCount, lastClickTime },
@@ -59,11 +61,11 @@ export function StatsBar() {
const fmt = numberFormat === "full" ? formatNumberFull : formatNumber;
// Rate-of-change indicator: show sparkle when TD/s increases
- const prevTdPerSecondRef = useRef(tdPerSecond);
+ const prevTdPerSecondRef = useRef(tdPerSecond);
const [rateBoosted, setRateBoosted] = useState(false);
useEffect(() => {
- if (tdPerSecond > prevTdPerSecondRef.current) {
+ if (tdPerSecond.gt(prevTdPerSecondRef.current)) {
setRateBoosted(true);
const timer = setTimeout(
() => setRateBoosted(false),
@@ -92,8 +94,8 @@ export function StatsBar() {
TD/s:{" "}
- 0 ? "green" : "dimmed"}>
- {tdPerSecond > 0 ? fmt(tdPerSecond) : "0.0"}
+
+ {tdPerSecond.gt(0) ? fmt(tdPerSecond) : "0.0"}
{rateBoosted && (
Click:{" "}
- {fmt(Math.floor(effectiveClickPower))} TD
+ {fmt(effectiveClickPower)} TD
{rebirthCount > 0 && (
diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx
index cd4a565..bb5d04b 100644
--- a/src/components/StatsPanel.tsx
+++ b/src/components/StatsPanel.tsx
@@ -1,6 +1,7 @@
import { Badge, Divider, Group, Modal, Stack, Text } from "@mantine/core";
import { ACHIEVEMENTS } from "../data/achievements";
import { useGameStore } from "../store";
+import { Decimal } from "../utils/decimal";
import { formatNumber } from "../utils/formatNumber";
function formatTime(totalSeconds: number): string {
@@ -74,9 +75,9 @@ export function StatsPanel({
0,
);
- const isBestRun = rebirthCount > 0 && totalTdEarned > lifetimeBestRunTd;
+ const isBestRun = rebirthCount > 0 && totalTdEarned.gt(lifetimeBestRunTd);
const isPeakTdPs =
- rebirthCount > 0 && peakTdPerSecond > lifetimePeakTdPerSecond;
+ rebirthCount > 0 && peakTdPerSecond.gt(lifetimePeakTdPerSecond);
return (
{
const dataBefore = useGameStore.getState().trainingData;
purchaseBulkUpgrade(id, qty);
- if (useGameStore.getState().trainingData !== dataBefore) {
+ if (!D(useGameStore.getState().trainingData).eq(dataBefore)) {
playPurchase();
}
},
@@ -119,7 +120,7 @@ export function UpgradesSidebar() {
(id: string) => {
const dataBefore = useGameStore.getState().trainingData;
purchaseClickUpgrade(id);
- if (useGameStore.getState().trainingData !== dataBefore) {
+ if (!D(useGameStore.getState().trainingData).eq(dataBefore)) {
playPurchase();
}
},
diff --git a/src/components/upgrades/BoosterCard.tsx b/src/components/upgrades/BoosterCard.tsx
index d67bb2d..e2b88fa 100644
--- a/src/components/upgrades/BoosterCard.tsx
+++ b/src/components/upgrades/BoosterCard.tsx
@@ -1,14 +1,16 @@
import { Badge, Button, Card, Group, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
+import type { DecimalSource } from "break_infinity.js";
import { useCallback, useRef, useState } from "react";
import type { Booster } from "../../data/boosters";
import { useReducedMotion } from "../../hooks/useReducedMotion";
+import { D } from "../../utils/decimal";
import { formatNumber } from "../../utils/formatNumber";
interface BoosterCardProps {
booster: Booster;
purchased: boolean;
- trainingData: number;
+ trainingData: DecimalSource;
evolutionStage: number;
onPurchase: (id: string) => void;
}
@@ -20,7 +22,7 @@ export function BoosterCard({
evolutionStage,
onPurchase,
}: BoosterCardProps) {
- const canAfford = !purchased && trainingData >= booster.cost;
+ const canAfford = !purchased && D(trainingData).gte(booster.cost);
const locked = evolutionStage < booster.unlockStage;
const [isGlowing, setIsGlowing] = useState(false);
const glowTimerRef = useRef>(undefined);
diff --git a/src/components/upgrades/ClickUpgradeCard.tsx b/src/components/upgrades/ClickUpgradeCard.tsx
index 6d41fc8..0083b1d 100644
--- a/src/components/upgrades/ClickUpgradeCard.tsx
+++ b/src/components/upgrades/ClickUpgradeCard.tsx
@@ -7,16 +7,18 @@ import {
Popover,
Text,
} from "@mantine/core";
+import type { DecimalSource } from "break_infinity.js";
import { useCallback, useRef, useState } from "react";
import type { ClickUpgrade } from "../../data/clickUpgrades";
import { useReducedMotion } from "../../hooks/useReducedMotion";
+import { D } from "../../utils/decimal";
import { formatNumber } from "../../utils/formatNumber";
import { ClickUpgradeTooltipContent } from "./ClickUpgradeTooltipContent";
interface ClickUpgradeCardProps {
upgrade: ClickUpgrade;
purchased: boolean;
- trainingData: number;
+ trainingData: DecimalSource;
evolutionStage: number;
onPurchase: (id: string) => void;
}
@@ -28,7 +30,7 @@ export function ClickUpgradeCard({
evolutionStage,
onPurchase,
}: ClickUpgradeCardProps) {
- const canAfford = !purchased && trainingData >= upgrade.cost;
+ const canAfford = !purchased && D(trainingData).gte(upgrade.cost);
const locked = evolutionStage < upgrade.unlockStage;
const [isGlowing, setIsGlowing] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
diff --git a/src/components/upgrades/UpgradeCard.tsx b/src/components/upgrades/UpgradeCard.tsx
index 6bf1fff..cb70cdb 100644
--- a/src/components/upgrades/UpgradeCard.tsx
+++ b/src/components/upgrades/UpgradeCard.tsx
@@ -8,6 +8,7 @@ import {
Text,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
+import type { DecimalSource } from "break_infinity.js";
import { useCallback, useEffect, useRef, useState } from "react";
import { MILESTONE_THRESHOLDS } from "../../data/milestones";
import { SYNERGIES } from "../../data/synergies";
@@ -20,6 +21,7 @@ import { getSynergyMultiplier } from "../../engine/synergyEngine";
import { getBulkCost, getMaxAffordable } from "../../engine/upgradeEngine";
import { useReducedMotion } from "../../hooks/useReducedMotion";
import type { BuyMode } from "../../store/settingsStore";
+import { D } from "../../utils/decimal";
import { formatNumber } from "../../utils/formatNumber";
import { GeneratorTooltipContent } from "./GeneratorTooltipContent";
@@ -27,7 +29,7 @@ interface UpgradeCardProps {
upgrade: Upgrade;
owned: number;
allOwned?: Record;
- trainingData: number;
+ trainingData: DecimalSource;
buyMode: BuyMode;
onPurchase: (id: string, count: number) => void;
costMultiplier?: number;
@@ -47,7 +49,7 @@ export function UpgradeCard({
? getMaxAffordable(upgrade, owned, trainingData, costMultiplier)
: buyMode;
const cost = getBulkCost(upgrade, owned, count, costMultiplier);
- const canAfford = count > 0 && trainingData >= cost;
+ const canAfford = count > 0 && D(trainingData).gte(cost);
const milestoneLevel = getMilestoneLevel(owned);
const milestoneMultiplier = getMilestoneMultiplier(owned);
diff --git a/src/components/upgrades/tooltipHelpers.ts b/src/components/upgrades/tooltipHelpers.ts
index 5d1f8cb..6bc251b 100644
--- a/src/components/upgrades/tooltipHelpers.ts
+++ b/src/components/upgrades/tooltipHelpers.ts
@@ -45,8 +45,9 @@ export function computeGeneratorTooltipData(
// cancel out in the percentage calculation — the % share is the same
// regardless of which global multipliers are active.
const grandTotal = getTotalTdPerSecond(UPGRADES, allOwned, 1, 1);
- const percentOfTotal =
- grandTotal > 0 ? (totalTdForGenerator / grandTotal) * 100 : 0;
+ const percentOfTotal = grandTotal.gt(0)
+ ? (totalTdForGenerator / grandTotal.toNumber()) * 100
+ : 0;
const nextThreshold = MILESTONE_THRESHOLDS[milestoneLevel] ?? null;
diff --git a/src/data/achievements.test.ts b/src/data/achievements.test.ts
index 973d9b7..fa142b1 100644
--- a/src/data/achievements.test.ts
+++ b/src/data/achievements.test.ts
@@ -1,11 +1,12 @@
import { describe, expect, it } from "vitest";
import type { GameState } from "../store/gameStore";
+import { D } from "../utils/decimal";
import { ACHIEVEMENTS } from "./achievements";
const emptyState: GameState = {
- trainingData: 0,
+ trainingData: D(0),
totalClicks: 0,
- totalTdEarned: 0,
+ totalTdEarned: D(0),
evolutionStage: 0,
lastSaved: 0,
upgradeOwned: {},
@@ -29,11 +30,11 @@ const emptyState: GameState = {
prestigeTokenBalance: 0,
hasOpenedPrestigeShop: false,
runStart: 0,
- peakTdPerSecond: 0,
+ peakTdPerSecond: D(0),
peakGeneratorsOwned: 0,
- lifetimeTdEarned: 0,
- lifetimePeakTdPerSecond: 0,
- lifetimeBestRunTd: 0,
+ lifetimeTdEarned: D(0),
+ lifetimePeakTdPerSecond: D(0),
+ lifetimeBestRunTd: D(0),
lifetimeWisdomEarned: 0,
activeChallengeId: null,
};
@@ -113,10 +114,12 @@ describe("ACHIEVEMENTS", () => {
it("td-1m fires when totalTdEarned >= 1_000_000", () => {
const a = ACHIEVEMENTS.find((x) => x.id === "td-1m");
expect(a).toBeDefined();
- expect(a?.condition({ ...emptyState, totalTdEarned: 1_000_000 })).toBe(
+ expect(a?.condition({ ...emptyState, totalTdEarned: D(1_000_000) })).toBe(
true,
);
- expect(a?.condition({ ...emptyState, totalTdEarned: 999_999 })).toBe(false);
+ expect(a?.condition({ ...emptyState, totalTdEarned: D(999_999) })).toBe(
+ false,
+ );
});
it("first-rebirth fires when rebirthCount >= 1", () => {
diff --git a/src/data/achievements.ts b/src/data/achievements.ts
index ddc4e61..adb18fb 100644
--- a/src/data/achievements.ts
+++ b/src/data/achievements.ts
@@ -86,19 +86,19 @@ export const ACHIEVEMENTS: readonly Achievement[] = [
id: "td-1k",
name: "Data Hoarder",
description: "Earn 1,000 TD total",
- condition: (s) => s.totalTdEarned >= 1_000,
+ condition: (s) => s.totalTdEarned.gte(1_000),
},
{
id: "td-1m",
name: "Big Data",
description: "Earn 1,000,000 TD total",
- condition: (s) => s.totalTdEarned >= 1_000_000,
+ condition: (s) => s.totalTdEarned.gte(1_000_000),
},
{
id: "td-1b",
name: "Data Singularity",
description: "Earn 1,000,000,000 TD total",
- condition: (s) => s.totalTdEarned >= 1_000_000_000,
+ condition: (s) => s.totalTdEarned.gte(1_000_000_000),
},
{
id: "first-rebirth",
@@ -164,6 +164,6 @@ export const ACHIEVEMENTS: readonly Achievement[] = [
id: "td-1t",
name: "Trillionaire",
description: "A trillion training data points. GLORP transcends.",
- condition: (s) => s.totalTdEarned >= 1_000_000_000_000,
+ condition: (s) => s.totalTdEarned.gte(1_000_000_000_000),
},
];
diff --git a/src/engine/achievementEngine.test.ts b/src/engine/achievementEngine.test.ts
index 8a941de..7f0ab60 100644
--- a/src/engine/achievementEngine.test.ts
+++ b/src/engine/achievementEngine.test.ts
@@ -1,12 +1,13 @@
import { describe, expect, it } from "vitest";
import type { Species } from "../data/species";
import type { GameState } from "../store/gameStore";
+import { D } from "../utils/decimal";
import { checkAchievements } from "./achievementEngine";
const baseState: GameState = {
- trainingData: 0,
+ trainingData: D(0),
totalClicks: 0,
- totalTdEarned: 0,
+ totalTdEarned: D(0),
evolutionStage: 0,
lastSaved: 0,
upgradeOwned: {},
@@ -30,11 +31,11 @@ const baseState: GameState = {
prestigeTokenBalance: 0,
hasOpenedPrestigeShop: false,
runStart: 0,
- peakTdPerSecond: 0,
+ peakTdPerSecond: D(0),
peakGeneratorsOwned: 0,
- lifetimeTdEarned: 0,
- lifetimePeakTdPerSecond: 0,
- lifetimeBestRunTd: 0,
+ lifetimeTdEarned: D(0),
+ lifetimePeakTdPerSecond: D(0),
+ lifetimeBestRunTd: D(0),
lifetimeWisdomEarned: 0,
activeChallengeId: null,
};
@@ -104,7 +105,7 @@ describe("checkAchievements", () => {
});
it("returns td-1m when totalTdEarned reaches 1M", () => {
- const state = { ...baseState, totalTdEarned: 1_000_000 };
+ const state = { ...baseState, totalTdEarned: D(1_000_000) };
const result = checkAchievements(state, ["td-1k"]);
expect(result).toContain("td-1m");
});
@@ -207,7 +208,7 @@ describe("checkAchievements", () => {
});
it("returns td-1t when totalTdEarned reaches 1 trillion", () => {
- const state = { ...baseState, totalTdEarned: 1_000_000_000_000 };
+ const state = { ...baseState, totalTdEarned: D(1_000_000_000_000) };
const result = checkAchievements(state, []);
expect(result).toContain("td-1t");
});
@@ -216,7 +217,7 @@ describe("checkAchievements", () => {
const state = {
...baseState,
totalClicks: 100_000,
- totalTdEarned: 1_000_000_000_000,
+ totalTdEarned: D(1_000_000_000_000),
evolutionStage: 4,
rebirthCount: 10,
upgradeOwned: { "neural-notepad": 100 },
diff --git a/src/engine/clickEngine.test.ts b/src/engine/clickEngine.test.ts
index 6ab13a5..516fb64 100644
--- a/src/engine/clickEngine.test.ts
+++ b/src/engine/clickEngine.test.ts
@@ -92,7 +92,7 @@ describe("computeClickPower", () => {
mockUpgrades,
0,
);
- expect(power).toBe(1);
+ expect(power.toNumber()).toBe(1);
});
it("returns floor of 1 even with upgrades at 0 tdPerSecond", () => {
@@ -101,7 +101,7 @@ describe("computeClickPower", () => {
mockUpgrades,
0,
);
- expect(power).toBe(1);
+ expect(power.toNumber()).toBe(1);
});
it("scales linearly with tdPerSecond", () => {
@@ -111,7 +111,7 @@ describe("computeClickPower", () => {
mockUpgrades,
tdPerSecond,
);
- expect(power).toBeCloseTo(BASE_CLICK_SECONDS * tdPerSecond);
+ expect(power.toNumber()).toBeCloseTo(BASE_CLICK_SECONDS * tdPerSecond);
});
it("adds purchased upgrade seconds to base", () => {
@@ -122,7 +122,7 @@ describe("computeClickPower", () => {
mockUpgrades,
tdPerSecond,
);
- expect(power).toBeCloseTo(expected);
+ expect(power.toNumber()).toBeCloseTo(expected);
});
it("stacks all purchased upgrades", () => {
@@ -136,7 +136,7 @@ describe("computeClickPower", () => {
mockUpgrades,
tdPerSecond,
);
- expect(power).toBeCloseTo(totalSeconds * tdPerSecond);
+ expect(power.toNumber()).toBeCloseTo(totalSeconds * tdPerSecond);
});
it("applies combo multiplier on top", () => {
@@ -153,7 +153,7 @@ describe("computeClickPower", () => {
now,
);
const expectedBase = BASE_CLICK_SECONDS * tdPerSecond;
- expect(power).toBeCloseTo(expectedBase * COMBO_MULTIPLIER);
+ expect(power.toNumber()).toBeCloseTo(expectedBase * COMBO_MULTIPLIER);
});
it("applies combo multiplier to the base-1 floor when tdPerSecond is 0", () => {
@@ -172,7 +172,7 @@ describe("computeClickPower", () => {
0,
now,
);
- expect(power).toBe(2);
+ expect(power.toNumber()).toBe(2);
});
it("applies species click multiplier", () => {
@@ -185,7 +185,9 @@ describe("computeClickPower", () => {
0,
1.5, // CHONK species
);
- expect(power).toBeCloseTo(BASE_CLICK_SECONDS * tdPerSecond * 1.5);
+ expect(power.toNumber()).toBeCloseTo(
+ BASE_CLICK_SECONDS * tdPerSecond * 1.5,
+ );
});
it("includes click mastery bonus seconds", () => {
@@ -201,7 +203,7 @@ describe("computeClickPower", () => {
undefined,
masteryLevel,
);
- expect(power).toBeCloseTo(expected);
+ expect(power.toNumber()).toBeCloseTo(expected);
});
it("click power scales with tdPerSecond — mid game example", () => {
@@ -212,7 +214,7 @@ describe("computeClickPower", () => {
CLICK_UPGRADES,
1000,
);
- expect(power).toBeGreaterThan(1000);
+ expect(power.toNumber()).toBeGreaterThan(1000);
});
it("click power scales with tdPerSecond — late game example", () => {
@@ -223,7 +225,7 @@ describe("computeClickPower", () => {
CLICK_UPGRADES,
30_000,
);
- expect(power).toBeGreaterThan(60_000);
+ expect(power.toNumber()).toBeGreaterThan(60_000);
});
});
diff --git a/src/engine/clickEngine.ts b/src/engine/clickEngine.ts
index 90bbf4e..59eb593 100644
--- a/src/engine/clickEngine.ts
+++ b/src/engine/clickEngine.ts
@@ -1,4 +1,6 @@
+import type { DecimalSource } from "break_infinity.js";
import type { ClickUpgrade } from "../data/clickUpgrades";
+import { D, Decimal } from "../utils/decimal";
/** Milliseconds between clicks to maintain combo (≈3 clicks/sec). */
export const COMBO_CLICK_WINDOW_MS = 333;
@@ -64,11 +66,11 @@ export function computeClickSeconds(
export function computeClickPower(
state: ClickPowerState,
clickUpgrades: readonly ClickUpgrade[],
- tdPerSecond: number,
+ tdPerSecond: DecimalSource,
now?: number,
clickMasteryBonus = 0,
speciesClickMultiplier = 1,
-): number {
+): Decimal {
const seconds = computeClickSeconds(
state.clickUpgradesPurchased,
clickUpgrades,
@@ -81,9 +83,9 @@ export function computeClickPower(
now,
);
- return Math.floor(
- Math.max(1, seconds * tdPerSecond * speciesClickMultiplier) * combo,
- );
+ return Decimal.max(1, D(seconds).mul(tdPerSecond).mul(speciesClickMultiplier))
+ .mul(combo)
+ .floor();
}
/**
@@ -115,14 +117,11 @@ export function updateCombo(lastClickTime: number, now: number): number {
if (lastClickTime === 0) return 1;
const elapsed = now - lastClickTime;
if (elapsed <= COMBO_CLICK_WINDOW_MS) {
- // Fast enough to build combo — but we don't know old count here,
- // caller adds to existing count
return -1; // sentinel: increment existing
}
if (elapsed > COMBO_DECAY_MS) {
return 1; // reset
}
- // Between combo window and decay: maintain but don't grow
return -1; // sentinel: increment existing
}
diff --git a/src/engine/dailyObjectivesEngine.test.ts b/src/engine/dailyObjectivesEngine.test.ts
index 4629e37..3a1d6c6 100644
--- a/src/engine/dailyObjectivesEngine.test.ts
+++ b/src/engine/dailyObjectivesEngine.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { D } from "../utils/decimal";
import {
checkObjectiveCompletion,
createSeededRandom,
@@ -15,7 +16,7 @@ import {
const baseGame = {
evolutionStage: 0,
upgradeOwned: {},
- totalTdEarned: 0,
+ totalTdEarned: D(0),
crossedMilestones: [] as number[],
currentSpecies: "GLORP" as const,
} satisfies Parameters[1];
@@ -265,7 +266,7 @@ describe("checkObjectiveCompletion — earn-td", () => {
expect(
checkObjectiveCompletion(
obj,
- { ...baseGame, totalTdEarned: 500_000 },
+ { ...baseGame, totalTdEarned: D(500_000) },
baseDaily,
),
).toBe(false);
@@ -274,7 +275,7 @@ describe("checkObjectiveCompletion — earn-td", () => {
expect(
checkObjectiveCompletion(
obj,
- { ...baseGame, totalTdEarned: 1_000_000 },
+ { ...baseGame, totalTdEarned: D(1_000_000) },
baseDaily,
),
).toBe(true);
diff --git a/src/engine/dailyObjectivesEngine.ts b/src/engine/dailyObjectivesEngine.ts
index a6c1083..9d56e26 100644
--- a/src/engine/dailyObjectivesEngine.ts
+++ b/src/engine/dailyObjectivesEngine.ts
@@ -261,7 +261,7 @@ export function checkObjectiveCompletion(
return daily.todayDidPrestigePurchase;
case "earn-td":
- return gameState.totalTdEarned >= (objective.targetTd ?? 0);
+ return gameState.totalTdEarned.gte(objective.targetTd ?? 0);
case "rebirth":
return daily.todayDidRebirth;
diff --git a/src/engine/evolutionEngine.ts b/src/engine/evolutionEngine.ts
index a2fbc6a..b3d00f8 100644
--- a/src/engine/evolutionEngine.ts
+++ b/src/engine/evolutionEngine.ts
@@ -1,4 +1,6 @@
+import type { DecimalSource } from "break_infinity.js";
import { STAGES } from "../data/stages";
+import { D } from "../utils/decimal";
/**
* Returns the evolution stage for a given total TD earned.
@@ -6,12 +8,13 @@ import { STAGES } from "../data/stages";
* A multiplier < 1 makes stages unlock earlier.
*/
export function getEvolutionStage(
- totalTdEarned: number,
+ totalTdEarned: DecimalSource,
thresholdMultiplier = 1,
): number {
+ const td = D(totalTdEarned);
let stage = 0;
for (const s of STAGES) {
- if (totalTdEarned >= s.unlockAt * thresholdMultiplier) {
+ if (td.gte(D(s.unlockAt).mul(thresholdMultiplier))) {
stage = s.stage;
}
}
diff --git a/src/engine/milestoneEngine.ts b/src/engine/milestoneEngine.ts
index c012a7d..f5acb77 100644
--- a/src/engine/milestoneEngine.ts
+++ b/src/engine/milestoneEngine.ts
@@ -1,4 +1,6 @@
+import type { DecimalSource } from "break_infinity.js";
import { MILESTONE_THRESHOLDS } from "../data/milestones";
+import { D } from "../utils/decimal";
/**
* Returns the number of milestone thresholds reached for the given owned count.
@@ -37,11 +39,13 @@ export const TD_MILESTONES: readonly number[] = [
* (prevTd, currentTd], excluding any already in `alreadyCrossed`.
*/
export function checkMilestones(
- prevTd: number,
- currentTd: number,
+ prevTd: DecimalSource,
+ currentTd: DecimalSource,
alreadyCrossed: Set,
): number[] {
+ const prev = D(prevTd);
+ const current = D(currentTd);
return TD_MILESTONES.filter(
- (t) => t > prevTd && t <= currentTd && !alreadyCrossed.has(t),
+ (t) => D(t).gt(prev) && D(t).lte(current) && !alreadyCrossed.has(t),
);
}
diff --git a/src/engine/offlineEngine.test.ts b/src/engine/offlineEngine.test.ts
index a99df07..f7f2222 100644
--- a/src/engine/offlineEngine.test.ts
+++ b/src/engine/offlineEngine.test.ts
@@ -70,7 +70,9 @@ describe("computeOfflineProgress", () => {
const result = computeOfflineProgress(lastSaved, BASE_NOW, state);
expect(result).not.toBeNull();
- expect(result?.earned).toBeCloseTo(0.2 * 14_400 * OFFLINE_EFFICIENCY);
+ expect(result?.earned.toNumber()).toBeCloseTo(
+ 0.2 * 14_400 * OFFLINE_EFFICIENCY,
+ );
expect(result?.cappedSeconds).toBeCloseTo(14_400);
expect(result?.elapsedSeconds).toBeCloseTo(14_400);
});
@@ -89,7 +91,9 @@ describe("computeOfflineProgress", () => {
const result = computeOfflineProgress(lastSaved, BASE_NOW, state);
expect(result).not.toBeNull();
- expect(result?.earned).toBeCloseTo(2.4 * 14_400 * OFFLINE_EFFICIENCY);
+ expect(result?.earned.toNumber()).toBeCloseTo(
+ 2.4 * 14_400 * OFFLINE_EFFICIENCY,
+ );
});
});
@@ -106,7 +110,7 @@ describe("computeOfflineProgress", () => {
expect(result?.cappedSeconds).toBe(OFFLINE_CAP_SECONDS); // 28,800
expect(result?.elapsedSeconds).toBeGreaterThan(OFFLINE_CAP_SECONDS);
// earned should be based on capped 8h, not 12h
- expect(result?.earned).toBeCloseTo(
+ expect(result?.earned.toNumber()).toBeCloseTo(
0.2 * OFFLINE_CAP_SECONDS * OFFLINE_EFFICIENCY,
);
});
@@ -133,7 +137,7 @@ describe("computeOfflineProgress", () => {
expect(result).not.toBeNull();
// 4.0 TD/s * 3,600s * 0.5 = 7,200
- expect(result?.earned).toBeCloseTo(7_200);
+ expect(result?.earned.toNumber()).toBeCloseTo(7_200);
});
});
diff --git a/src/engine/offlineEngine.ts b/src/engine/offlineEngine.ts
index 0c5db62..7866cb7 100644
--- a/src/engine/offlineEngine.ts
+++ b/src/engine/offlineEngine.ts
@@ -1,3 +1,4 @@
+import type { Decimal } from "../utils/decimal";
import type { Mood } from "./moodEngine";
import { computeTick } from "./tickEngine";
@@ -16,7 +17,7 @@ interface OfflineState {
}
export interface OfflineProgressResult {
- earned: number;
+ earned: Decimal;
elapsedSeconds: number;
cappedSeconds: number;
welcomeMessage: string;
@@ -46,9 +47,9 @@ export function computeOfflineProgress(
// Reuse computeTick for TD calculation (no duplicated TD/s logic)
const tickResult = computeTick(state, cappedSeconds, now);
- const earned = tickResult.trainingDataDelta * offlineEfficiency;
+ const earned = tickResult.trainingDataDelta.mul(offlineEfficiency);
- if (earned === 0) return null;
+ if (earned.eq(0)) return null;
// Use the decayed mood (if any) for the welcome message
const currentMood = tickResult.newMood ?? state.mood;
diff --git a/src/engine/rebirthEngine.ts b/src/engine/rebirthEngine.ts
index 50e3414..6486478 100644
--- a/src/engine/rebirthEngine.ts
+++ b/src/engine/rebirthEngine.ts
@@ -1,6 +1,8 @@
+import type { DecimalSource } from "break_infinity.js";
import type { Species } from "../data/species";
import { SPECIES_ORDER } from "../data/species";
import { STAGES } from "../data/stages";
+import { D } from "../utils/decimal";
/** Minimum evolution stage required to trigger a Rebirth. */
export const REBIRTH_MIN_STAGE = 4;
@@ -16,11 +18,11 @@ export const WISDOM_TOKENS_DIVISOR = 5_000_000;
* The optional `tokenMagnetMultiplier` scales the result (default 1).
*/
export function computeWisdomTokens(
- totalTdEarned: number,
+ totalTdEarned: DecimalSource,
tokenMagnetMultiplier = 1,
): number {
- const base = Math.floor(Math.sqrt(totalTdEarned / WISDOM_TOKENS_DIVISOR));
- return Math.floor(base * tokenMagnetMultiplier);
+ const base = D(totalTdEarned).div(WISDOM_TOKENS_DIVISOR).sqrt().floor();
+ return base.mul(tokenMagnetMultiplier).floor().toNumber();
}
/** Returns true when the player is eligible to Rebirth. */
@@ -48,12 +50,13 @@ export function getRebirthThresholdTd(thresholdMultiplier = 1): number {
* `thresholdMultiplier` mirrors the Evolution Accelerator prestige upgrade.
*/
export function getRebirthProgress(
- totalTdEarned: number,
+ totalTdEarned: DecimalSource,
thresholdMultiplier = 1,
): number {
+ const td = D(totalTdEarned);
const threshold = getRebirthThresholdTd(thresholdMultiplier);
- if (totalTdEarned >= threshold) return 1;
- return Math.max(0, totalTdEarned / threshold);
+ if (td.gte(threshold)) return 1;
+ return Math.max(0, td.div(threshold).toNumber());
}
/**
diff --git a/src/engine/tickEngine.test.ts b/src/engine/tickEngine.test.ts
index 8f5ae22..5d91f65 100644
--- a/src/engine/tickEngine.test.ts
+++ b/src/engine/tickEngine.test.ts
@@ -20,7 +20,7 @@ function makeState(
describe("computeTick", () => {
it("returns zero delta when no upgrades are owned", () => {
const result = computeTick(makeState({}), 1, BASE_TIME);
- expect(result.trainingDataDelta).toBe(0);
+ expect(result.trainingDataDelta.toNumber()).toBe(0);
});
it("returns zero delta when deltaSeconds is 0", () => {
@@ -29,7 +29,7 @@ describe("computeTick", () => {
0,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBe(0);
+ expect(result.trainingDataDelta.toNumber()).toBe(0);
});
it("computes correct delta for one upgrade owned", () => {
@@ -39,7 +39,7 @@ describe("computeTick", () => {
1,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(0.2);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.2);
});
it("scales with number of upgrades owned", () => {
@@ -49,7 +49,7 @@ describe("computeTick", () => {
1,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(0.6);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.6);
});
it("sums across multiple upgrade types", () => {
@@ -62,7 +62,7 @@ describe("computeTick", () => {
1,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(2.4);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(2.4);
});
it("scales with delta time", () => {
@@ -72,7 +72,7 @@ describe("computeTick", () => {
2.5,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(0.5);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.5);
});
it("handles fractional delta seconds", () => {
@@ -82,7 +82,7 @@ describe("computeTick", () => {
0.016,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(320);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(320);
});
it("handles all upgrade types combined", () => {
@@ -100,7 +100,7 @@ describe("computeTick", () => {
1,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(22_222.2);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(22_222.2);
});
describe("booster multiplier in tick", () => {
@@ -110,7 +110,7 @@ describe("computeTick", () => {
1,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(0.2);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.2);
});
it("applies series-a-funding 2x multiplier", () => {
@@ -123,7 +123,7 @@ describe("computeTick", () => {
BASE_TIME,
);
// neural-notepad 0.2 TD/s * 2 = 0.4
- expect(result.trainingDataDelta).toBeCloseTo(0.4);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.4);
});
it("stacks two booster multipliers multiplicatively", () => {
@@ -136,7 +136,7 @@ describe("computeTick", () => {
BASE_TIME,
);
// neural-notepad 0.2 TD/s * 2 * 3 = 1.2
- expect(result.trainingDataDelta).toBeCloseTo(1.2);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(1.2);
});
it("uses default 1x when boostersPurchased is undefined", () => {
@@ -145,7 +145,7 @@ describe("computeTick", () => {
1,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(0.2);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.2);
});
});
@@ -206,7 +206,7 @@ describe("computeTick", () => {
1,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBe(0);
+ expect(result.trainingDataDelta.toNumber()).toBe(0);
});
it("click-only challenge still decays mood", () => {
@@ -218,7 +218,7 @@ describe("computeTick", () => {
1,
BASE_TIME + 60_000,
);
- expect(result.trainingDataDelta).toBe(0);
+ expect(result.trainingDataDelta.toNumber()).toBe(0);
expect(result.newMood).toBe("Neutral");
});
@@ -231,7 +231,7 @@ describe("computeTick", () => {
1,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(0.2);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.2);
});
it("non-click-only challenge does not disable auto-gen", () => {
@@ -243,7 +243,7 @@ describe("computeTick", () => {
1,
BASE_TIME,
);
- expect(result.trainingDataDelta).toBeCloseTo(0.2);
+ expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.2);
});
});
});
diff --git a/src/engine/tickEngine.ts b/src/engine/tickEngine.ts
index e3479dc..0dec62f 100644
--- a/src/engine/tickEngine.ts
+++ b/src/engine/tickEngine.ts
@@ -1,5 +1,7 @@
import { BOOSTERS } from "../data/boosters";
import { UPGRADES } from "../data/upgrades";
+import type { Decimal } from "../utils/decimal";
+import { D } from "../utils/decimal";
import type { Mood } from "./moodEngine";
import { getDecayedMood } from "./moodEngine";
import { computeBoosterMultiplier, getTotalTdPerSecond } from "./upgradeEngine";
@@ -15,7 +17,7 @@ interface TickState {
}
interface TickResult {
- trainingDataDelta: number;
+ trainingDataDelta: Decimal;
newMood: Mood | null;
}
@@ -29,7 +31,7 @@ export function computeTick(
// Click-Only challenge: auto-generators produce no TD
if (state.activeChallengeId === "click-only") {
- return { trainingDataDelta: 0, newMood };
+ return { trainingDataDelta: D(0), newMood };
}
const globalMultiplier =
@@ -46,7 +48,7 @@ export function computeTick(
);
return {
- trainingDataDelta: tdPerSecond * deltaSeconds,
+ trainingDataDelta: tdPerSecond.mul(deltaSeconds),
newMood,
};
}
diff --git a/src/engine/upgradeEngine.test.ts b/src/engine/upgradeEngine.test.ts
index 79b9ed7..04eb67e 100644
--- a/src/engine/upgradeEngine.test.ts
+++ b/src/engine/upgradeEngine.test.ts
@@ -34,81 +34,95 @@ const mockUpgrade2: Upgrade = {
describe("getUpgradeCost", () => {
it("returns baseCost when owned is 0", () => {
- expect(getUpgradeCost(mockUpgrade, 0)).toBe(100);
+ expect(getUpgradeCost(mockUpgrade, 0).toNumber()).toBe(100);
});
it("scales cost by 1.15 for each owned", () => {
- expect(getUpgradeCost(mockUpgrade, 1)).toBe(Math.floor(100 * 1.15));
+ expect(getUpgradeCost(mockUpgrade, 1).toNumber()).toBe(115);
});
it("scales exponentially for multiple owned", () => {
- expect(getUpgradeCost(mockUpgrade, 5)).toBe(Math.floor(100 * 1.15 ** 5));
+ expect(getUpgradeCost(mockUpgrade, 5).toNumber()).toBe(
+ Math.floor(100 * 1.15 ** 5),
+ );
});
it("scales correctly for 10 owned", () => {
- expect(getUpgradeCost(mockUpgrade, 10)).toBe(Math.floor(100 * 1.15 ** 10));
+ expect(getUpgradeCost(mockUpgrade, 10).toNumber()).toBe(
+ Math.floor(100 * 1.15 ** 10),
+ );
});
it("floors the result to an integer", () => {
- const cost = getUpgradeCost(mockUpgrade, 1);
+ const cost = getUpgradeCost(mockUpgrade, 1).toNumber();
expect(Number.isInteger(cost)).toBe(true);
});
it("works with different base costs", () => {
- expect(getUpgradeCost(mockUpgrade2, 0)).toBe(500);
- expect(getUpgradeCost(mockUpgrade2, 3)).toBe(Math.floor(500 * 1.15 ** 3));
+ expect(getUpgradeCost(mockUpgrade2, 0).toNumber()).toBe(500);
+ expect(getUpgradeCost(mockUpgrade2, 3).toNumber()).toBe(
+ Math.floor(500 * 1.15 ** 3),
+ );
});
it("applies custom costMultiplier", () => {
- expect(getUpgradeCost(mockUpgrade, 1, 1.1)).toBe(Math.floor(100 * 1.1));
+ expect(getUpgradeCost(mockUpgrade, 1, 1.1).toNumber()).toBe(
+ Math.floor(100 * 1.1),
+ );
});
it("defaults to COST_MULTIPLIER when costMultiplier is omitted", () => {
- expect(getUpgradeCost(mockUpgrade, 3)).toBe(
- getUpgradeCost(mockUpgrade, 3, COST_MULTIPLIER),
+ expect(getUpgradeCost(mockUpgrade, 3).toNumber()).toBe(
+ getUpgradeCost(mockUpgrade, 3, COST_MULTIPLIER).toNumber(),
);
});
});
describe("getBulkCost", () => {
it("returns 0 for count 0", () => {
- expect(getBulkCost(mockUpgrade, 0, 0)).toBe(0);
+ expect(getBulkCost(mockUpgrade, 0, 0).toNumber()).toBe(0);
});
it("returns negative count as 0", () => {
- expect(getBulkCost(mockUpgrade, 0, -1)).toBe(0);
+ expect(getBulkCost(mockUpgrade, 0, -1).toNumber()).toBe(0);
});
it("matches getUpgradeCost for count 1 at 0 owned", () => {
- expect(getBulkCost(mockUpgrade, 0, 1)).toBe(getUpgradeCost(mockUpgrade, 0));
+ expect(getBulkCost(mockUpgrade, 0, 1).toNumber()).toBe(
+ getUpgradeCost(mockUpgrade, 0).toNumber(),
+ );
});
it("matches getUpgradeCost for count 1 at 5 owned", () => {
- expect(getBulkCost(mockUpgrade, 5, 1)).toBe(getUpgradeCost(mockUpgrade, 5));
+ expect(getBulkCost(mockUpgrade, 5, 1).toNumber()).toBe(
+ getUpgradeCost(mockUpgrade, 5).toNumber(),
+ );
});
it("is greater than single cost and less than 10x the last cost for count 10", () => {
- const singleCost = getBulkCost(mockUpgrade, 0, 1);
- const tenthCost = getUpgradeCost(mockUpgrade, 9);
- const bulk10 = getBulkCost(mockUpgrade, 0, 10);
+ const singleCost = getBulkCost(mockUpgrade, 0, 1).toNumber();
+ const tenthCost = getUpgradeCost(mockUpgrade, 9).toNumber();
+ const bulk10 = getBulkCost(mockUpgrade, 0, 10).toNumber();
expect(bulk10).toBeGreaterThan(singleCost);
expect(bulk10).toBeLessThan(10 * tenthCost + 1);
});
it("is more expensive when owning more already", () => {
- expect(getBulkCost(mockUpgrade, 10, 5)).toBeGreaterThan(
- getBulkCost(mockUpgrade, 0, 5),
+ expect(getBulkCost(mockUpgrade, 10, 5).toNumber()).toBeGreaterThan(
+ getBulkCost(mockUpgrade, 0, 5).toNumber(),
);
});
it("returns an integer", () => {
- expect(Number.isInteger(getBulkCost(mockUpgrade, 3, 7))).toBe(true);
+ expect(Number.isInteger(getBulkCost(mockUpgrade, 3, 7).toNumber())).toBe(
+ true,
+ );
});
it("works with different base costs", () => {
- expect(getBulkCost(mockUpgrade2, 0, 1)).toBe(500);
+ expect(getBulkCost(mockUpgrade2, 0, 1).toNumber()).toBe(500);
// count=2 should cost more than 1 but less than 2x the second-upgrade cost
- const bulk2 = getBulkCost(mockUpgrade2, 0, 2);
+ const bulk2 = getBulkCost(mockUpgrade2, 0, 2).toNumber();
expect(bulk2).toBeGreaterThan(500);
expect(bulk2).toBeLessThan(2 * Math.floor(500 * COST_MULTIPLIER) + 1);
});
@@ -137,16 +151,20 @@ describe("getMaxAffordable", () => {
});
it("returns 0 when budget is one less than first cost at 5 owned", () => {
- const firstCost = getUpgradeCost(mockUpgrade, 5);
+ const firstCost = getUpgradeCost(mockUpgrade, 5).toNumber();
expect(getMaxAffordable(mockUpgrade, 5, firstCost - 1)).toBe(0);
});
it("affordable count is consistent: buying that many should not exceed budget", () => {
const budget = 50000;
const n = getMaxAffordable(mockUpgrade, 0, budget);
- expect(getBulkCost(mockUpgrade, 0, n)).toBeLessThanOrEqual(budget);
+ expect(getBulkCost(mockUpgrade, 0, n).toNumber()).toBeLessThanOrEqual(
+ budget,
+ );
if (n > 0) {
- expect(getBulkCost(mockUpgrade, 0, n + 1)).toBeGreaterThan(budget);
+ expect(getBulkCost(mockUpgrade, 0, n + 1).toNumber()).toBeGreaterThan(
+ budget,
+ );
}
});
@@ -154,94 +172,114 @@ describe("getMaxAffordable", () => {
const budget = 1_000_000;
const n = getMaxAffordable(mockUpgrade, 0, budget);
expect(n).toBeGreaterThan(0);
- expect(getBulkCost(mockUpgrade, 0, n)).toBeLessThanOrEqual(budget);
+ expect(getBulkCost(mockUpgrade, 0, n).toNumber()).toBeLessThanOrEqual(
+ budget,
+ );
});
});
describe("getTotalTdPerSecond", () => {
it("returns 0 when no upgrades are owned", () => {
- expect(getTotalTdPerSecond([mockUpgrade, mockUpgrade2], {})).toBe(0);
+ expect(
+ getTotalTdPerSecond([mockUpgrade, mockUpgrade2], {}).toNumber(),
+ ).toBe(0);
});
it("returns correct TD/s for a single owned upgrade", () => {
const owned = { "test-upgrade": 3 };
- expect(getTotalTdPerSecond([mockUpgrade], owned)).toBeCloseTo(4.5);
+ expect(getTotalTdPerSecond([mockUpgrade], owned).toNumber()).toBeCloseTo(
+ 4.5,
+ );
});
it("sums TD/s across multiple upgrade types", () => {
const owned = { "test-upgrade": 2, "test-upgrade-2": 1 };
- expect(getTotalTdPerSecond([mockUpgrade, mockUpgrade2], owned)).toBeCloseTo(
- 8,
- );
+ expect(
+ getTotalTdPerSecond([mockUpgrade, mockUpgrade2], owned).toNumber(),
+ ).toBeCloseTo(8);
});
it("ignores upgrades not in the owned map", () => {
const owned = { "test-upgrade": 1 };
- expect(getTotalTdPerSecond([mockUpgrade, mockUpgrade2], owned)).toBeCloseTo(
- 1.5,
- );
+ expect(
+ getTotalTdPerSecond([mockUpgrade, mockUpgrade2], owned).toNumber(),
+ ).toBeCloseTo(1.5);
});
it("handles empty upgrades array", () => {
- expect(getTotalTdPerSecond([], { "test-upgrade": 5 })).toBe(0);
+ expect(getTotalTdPerSecond([], { "test-upgrade": 5 }).toNumber()).toBe(0);
});
it("applies globalMultiplier correctly", () => {
const owned = { "test-upgrade": 1 };
- expect(getTotalTdPerSecond([mockUpgrade], owned, 2)).toBeCloseTo(3);
+ expect(getTotalTdPerSecond([mockUpgrade], owned, 2).toNumber()).toBeCloseTo(
+ 3,
+ );
});
it("applies boosterMultiplier correctly", () => {
const owned = { "test-upgrade": 1 };
- expect(getTotalTdPerSecond([mockUpgrade], owned, 1, 3)).toBeCloseTo(4.5);
+ expect(
+ getTotalTdPerSecond([mockUpgrade], owned, 1, 3).toNumber(),
+ ).toBeCloseTo(4.5);
});
it("applies both multipliers combined", () => {
const owned = { "test-upgrade": 1 };
// base 1.5 * wisdom 2 * booster 3 = 9
- expect(getTotalTdPerSecond([mockUpgrade], owned, 2, 3)).toBeCloseTo(9);
+ expect(
+ getTotalTdPerSecond([mockUpgrade], owned, 2, 3).toNumber(),
+ ).toBeCloseTo(9);
});
it("applies milestone multiplier at 10 owned (\u00d71.5)", () => {
const owned = { "test-upgrade": 10 };
// 10 * 1.5 baseTdPerSecond * 1.5 milestone = 22.5
- expect(getTotalTdPerSecond([mockUpgrade], owned)).toBeCloseTo(22.5);
+ expect(getTotalTdPerSecond([mockUpgrade], owned).toNumber()).toBeCloseTo(
+ 22.5,
+ );
});
it("applies milestone multiplier at 25 owned (\u00d72)", () => {
const owned = { "test-upgrade": 25 };
// 25 * 1.5 * 2 = 75
- expect(getTotalTdPerSecond([mockUpgrade], owned)).toBeCloseTo(75);
+ expect(getTotalTdPerSecond([mockUpgrade], owned).toNumber()).toBeCloseTo(
+ 75,
+ );
});
it("applies milestone multiplier at 50 owned (\u00d73)", () => {
const owned = { "test-upgrade": 50 };
// 50 * 1.5 * 3 = 225
- expect(getTotalTdPerSecond([mockUpgrade], owned)).toBeCloseTo(225);
+ expect(getTotalTdPerSecond([mockUpgrade], owned).toNumber()).toBeCloseTo(
+ 225,
+ );
});
it("applies milestone multiplier at 100 owned (\u00d76)", () => {
const owned = { "test-upgrade": 100 };
// 100 * 1.5 * 6 = 900
- expect(getTotalTdPerSecond([mockUpgrade], owned)).toBeCloseTo(900);
+ expect(getTotalTdPerSecond([mockUpgrade], owned).toNumber()).toBeCloseTo(
+ 900,
+ );
});
it("applies milestone per-generator independently", () => {
// mockUpgrade: 10 owned \u2192 \u00d71.5 milestone; mockUpgrade2: 3 owned \u2192 \u00d71 milestone
const owned = { "test-upgrade": 10, "test-upgrade-2": 3 };
// 10 * 1.5 * 1.5 + 3 * 5 * 1 = 22.5 + 15 = 37.5
- expect(getTotalTdPerSecond([mockUpgrade, mockUpgrade2], owned)).toBeCloseTo(
- 37.5,
- );
+ expect(
+ getTotalTdPerSecond([mockUpgrade, mockUpgrade2], owned).toNumber(),
+ ).toBeCloseTo(37.5);
});
it("applies no synergy for custom IDs not in the synergy map", () => {
// test-upgrade and test-upgrade-2 are not synergy sources or targets
const owned = { "test-upgrade": 50, "test-upgrade-2": 50 };
// milestone at 50 \u2192 \u00d73; no synergy; 50*1.5*3 + 50*5*3 = 225 + 750 = 975
- expect(getTotalTdPerSecond([mockUpgrade, mockUpgrade2], owned)).toBeCloseTo(
- 975,
- );
+ expect(
+ getTotalTdPerSecond([mockUpgrade, mockUpgrade2], owned).toNumber(),
+ ).toBeCloseTo(975);
});
});
@@ -273,7 +311,7 @@ describe("getTotalTdPerSecond \u2014 synergy integration", () => {
// neural-notepad: 49 owned \u2192 milestone \u00d72 (10 and 25 crossed), no synergy \u2192 49*1*2=98
// pattern-antenna: 5 owned \u2192 milestone \u00d71, no synergy \u2192 5*2*1=10
expect(
- getTotalTdPerSecond([neuralNotepad, patternAntenna], owned),
+ getTotalTdPerSecond([neuralNotepad, patternAntenna], owned).toNumber(),
).toBeCloseTo(108);
});
@@ -282,7 +320,7 @@ describe("getTotalTdPerSecond \u2014 synergy integration", () => {
// neural-notepad: 50 owned \u2192 milestone \u00d73, synergy \u00d72 (target of itself) \u2192 50*1*3*2=300
// pattern-antenna: 5 owned \u2192 milestone \u00d71, synergy \u00d72 \u2192 5*2*1*2=20
expect(
- getTotalTdPerSecond([neuralNotepad, patternAntenna], owned),
+ getTotalTdPerSecond([neuralNotepad, patternAntenna], owned).toNumber(),
).toBeCloseTo(320);
});
@@ -290,7 +328,9 @@ describe("getTotalTdPerSecond \u2014 synergy integration", () => {
// neural-notepad at 50: milestone \u00d73, synergy (self) \u00d72 \u2192 effective rate 1*3*2=6 per unit
const owned = { "neural-notepad": 50 };
// 50 * 1 * 3 * 2 = 300
- expect(getTotalTdPerSecond([neuralNotepad], owned)).toBeCloseTo(300);
+ expect(getTotalTdPerSecond([neuralNotepad], owned).toNumber()).toBeCloseTo(
+ 300,
+ );
});
});
diff --git a/src/engine/upgradeEngine.ts b/src/engine/upgradeEngine.ts
index 5ec4b97..36bba50 100644
--- a/src/engine/upgradeEngine.ts
+++ b/src/engine/upgradeEngine.ts
@@ -1,5 +1,7 @@
+import type { DecimalSource } from "break_infinity.js";
import type { Booster } from "../data/boosters";
import type { Upgrade } from "../data/upgrades";
+import { D, Decimal } from "../utils/decimal";
import { getMilestoneMultiplier } from "./milestoneEngine";
import { getSynergyMultiplier } from "./synergyEngine";
@@ -9,8 +11,8 @@ export function getUpgradeCost(
upgrade: Upgrade,
owned: number,
costMultiplier = COST_MULTIPLIER,
-): number {
- return Math.floor(upgrade.baseCost * costMultiplier ** owned);
+): Decimal {
+ return D(upgrade.baseCost).mul(D(costMultiplier).pow(owned)).floor();
}
/**
@@ -22,13 +24,14 @@ export function getBulkCost(
owned: number,
count: number,
costMultiplier = COST_MULTIPLIER,
-): number {
- if (count <= 0) return 0;
+): Decimal {
+ if (count <= 0) return D(0);
if (count === 1) return getUpgradeCost(upgrade, owned, costMultiplier);
- const firstCost = upgrade.baseCost * costMultiplier ** owned;
- return Math.floor(
- (firstCost * (costMultiplier ** count - 1)) / (costMultiplier - 1),
- );
+ const firstCost = D(upgrade.baseCost).mul(D(costMultiplier).pow(owned));
+ return firstCost
+ .mul(D(costMultiplier).pow(count).sub(1))
+ .div(D(costMultiplier).sub(1))
+ .floor();
}
/**
@@ -38,15 +41,16 @@ export function getBulkCost(
export function getMaxAffordable(
upgrade: Upgrade,
owned: number,
- budget: number,
+ budget: DecimalSource,
costMultiplier = COST_MULTIPLIER,
): number {
- if (budget <= 0) return 0;
- const firstCost = upgrade.baseCost * costMultiplier ** owned;
- if (budget < firstCost) return 0;
+ const b = new Decimal(budget);
+ if (b.lte(0)) return 0;
+ const firstCost = D(upgrade.baseCost).mul(D(costMultiplier).pow(owned));
+ if (b.lt(firstCost)) return 0;
const n = Math.floor(
- Math.log((budget * (costMultiplier - 1)) / firstCost + 1) /
- Math.log(costMultiplier),
+ b.mul(D(costMultiplier).sub(1)).div(firstCost).add(1).log10() /
+ D(costMultiplier).log10(),
);
return Math.max(0, n);
}
@@ -74,14 +78,18 @@ export function getTotalTdPerSecond(
owned: Record,
globalMultiplier = 1,
boosterMultiplier = 1,
-): number {
- let total = 0;
+): Decimal {
+ let total = D(0);
for (const upgrade of upgrades) {
const count = owned[upgrade.id] ?? 0;
const milestoneMultiplier = getMilestoneMultiplier(count);
const synergyMultiplier = getSynergyMultiplier(upgrade.id, owned);
- total +=
- upgrade.baseTdPerSecond * count * milestoneMultiplier * synergyMultiplier;
+ total = total.add(
+ D(upgrade.baseTdPerSecond)
+ .mul(count)
+ .mul(milestoneMultiplier)
+ .mul(synergyMultiplier),
+ );
}
- return total * globalMultiplier * boosterMultiplier;
+ return total.mul(globalMultiplier).mul(boosterMultiplier);
}
diff --git a/src/hooks/useGameLoop.ts b/src/hooks/useGameLoop.ts
index 5e6ccbc..c3b63bd 100644
--- a/src/hooks/useGameLoop.ts
+++ b/src/hooks/useGameLoop.ts
@@ -28,6 +28,7 @@ import {
import { useGameStore } from "../store";
import { useDailyStore } from "../store/dailyStore";
import { useUIStore } from "../store/uiStore";
+import type { Decimal } from "../utils/decimal";
const TICK_INTERVAL_MS = 1000;
@@ -93,7 +94,7 @@ export function useGameLoop() {
now,
);
- if (result.trainingDataDelta > 0) {
+ if (result.trainingDataDelta.gt(0)) {
state.addTrainingData(result.trainingDataDelta);
// Check for milestone crossings and fire celebration events.
@@ -156,14 +157,14 @@ export function useGameLoop() {
const costMult = getGeneratorCostMultiplier(
effectivePrestige["generator-discount"] ?? 0,
);
- let cheapest: { id: string; cost: number } | null = null;
+ let cheapest: { id: string; cost: Decimal } | null = null;
for (const u of UPGRADES) {
if (current.evolutionStage < u.unlockStage) continue;
const owned = current.upgradeOwned[u.id] ?? 0;
const cost = getUpgradeCost(u, owned, costMult);
if (
- cost <= current.trainingData &&
- (cheapest === null || cost < cheapest.cost)
+ cost.lte(current.trainingData) &&
+ (cheapest === null || cost.lt(cheapest.cost))
) {
cheapest = { id: u.id, cost };
}
diff --git a/src/hooks/useInterpolatedTd.test.ts b/src/hooks/useInterpolatedTd.test.ts
index a65aa11..2d7f7c7 100644
--- a/src/hooks/useInterpolatedTd.test.ts
+++ b/src/hooks/useInterpolatedTd.test.ts
@@ -1,14 +1,15 @@
import { describe, expect, it } from "vitest";
+import { D } from "../utils/decimal";
import { interpolateTd } from "./useInterpolatedTd";
describe("interpolateTd", () => {
describe("snaps when no passive income", () => {
it("returns actual when tdPerSecond is 0", () => {
- expect(interpolateTd(100, 105, 0, 0.016)).toBe(105);
+ expect(interpolateTd(D(100), D(105), D(0), 0.016).toNumber()).toBe(105);
});
it("returns actual when tdPerSecond is negative", () => {
- expect(interpolateTd(100, 50, -1, 0.016)).toBe(50);
+ expect(interpolateTd(D(100), D(50), D(-1), 0.016).toNumber()).toBe(50);
});
});
@@ -16,12 +17,12 @@ describe("interpolateTd", () => {
it("snaps when prev is more than 1.5 ticks ahead of actual", () => {
// tdPerSecond=10, 1.5 ticks = 15 TD drift threshold
// prev=120, actual=100 → drift=20 > 15 → snap
- expect(interpolateTd(120, 100, 10, 0.016)).toBe(100);
+ expect(interpolateTd(D(120), D(100), D(10), 0.016).toNumber()).toBe(100);
});
it("does NOT snap for normal interpolation ahead of actual", () => {
// prev=105, actual=100 → drift=5 < 15 → interpolate
- const result = interpolateTd(105, 100, 10, 0.016);
+ const result = interpolateTd(D(105), D(100), D(10), 0.016).toNumber();
expect(result).toBeGreaterThan(105); // keeps counting
expect(result).toBeLessThanOrEqual(110); // capped at actual + 1 tick
});
@@ -31,12 +32,12 @@ describe("interpolateTd", () => {
it("snaps when actual is more than 2 ticks ahead of prev", () => {
// tdPerSecond=10, 2 ticks = 20 TD threshold
// actual=130, prev=100 → gap=30 > 20 → snap
- expect(interpolateTd(100, 130, 10, 0.016)).toBe(130);
+ expect(interpolateTd(D(100), D(130), D(10), 0.016).toNumber()).toBe(130);
});
it("does NOT snap for a normal tick update", () => {
// prev=99, actual=100 (just ticked), gap=1 < 20 → interpolate
- const result = interpolateTd(99, 100, 10, 0.016);
+ const result = interpolateTd(D(99), D(100), D(10), 0.016).toNumber();
expect(result).toBeGreaterThan(99); // keeps counting
});
});
@@ -44,32 +45,35 @@ describe("interpolateTd", () => {
describe("normal interpolation", () => {
it("advances prev by tdPerSecond * elapsed each frame", () => {
// prev=100, actual=100, tdPerSecond=10, elapsed=0.016 (1 frame @ 60fps)
- const result = interpolateTd(100, 100, 10, 0.016);
+ const result = interpolateTd(D(100), D(100), D(10), 0.016).toNumber();
expect(result).toBeCloseTo(100.16, 1);
});
it("caps at actual + tdPerSecond (one tick ahead)", () => {
// prev=109, actual=100, tdPerSecond=10, ceiling=110
// next would be 109 + 10*10 = 209, but capped at 110
- const result = interpolateTd(109, 100, 10, 10);
+ const result = interpolateTd(D(109), D(100), D(10), 10).toNumber();
expect(result).toBe(110);
});
it("continues smoothly after tick fires", () => {
// Tick fires: actual jumps from 100 to 110, prev was interpolated to ~110
// Now prev≈110, actual=110, tdPerSecond=10
- const result = interpolateTd(110, 110, 10, 0.016);
+ const result = interpolateTd(D(110), D(110), D(10), 0.016).toNumber();
expect(result).toBeCloseTo(110.16, 1);
});
});
describe("edge cases", () => {
it("handles zero actual and zero prev", () => {
- expect(interpolateTd(0, 0, 10, 0.016)).toBeCloseTo(0.16, 1);
+ expect(interpolateTd(D(0), D(0), D(10), 0.016).toNumber()).toBeCloseTo(
+ 0.16,
+ 1,
+ );
});
it("handles very small tdPerSecond", () => {
- const result = interpolateTd(0.05, 0, 0.1, 0.016);
+ const result = interpolateTd(D(0.05), D(0), D(0.1), 0.016).toNumber();
// drift = 0.05 - 0 = 0.05 > 0.1 * 1.5 = 0.15? No → interpolate
expect(result).toBeCloseTo(0.0516, 3);
});
diff --git a/src/hooks/useInterpolatedTd.ts b/src/hooks/useInterpolatedTd.ts
index d7e38e6..f1396f8 100644
--- a/src/hooks/useInterpolatedTd.ts
+++ b/src/hooks/useInterpolatedTd.ts
@@ -4,6 +4,7 @@ import { getSpeciesBonus } from "../data/species";
import { UPGRADES } from "../data/upgrades";
import { getTotalTdPerSecond } from "../engine/upgradeEngine";
import { useGameStore } from "../store";
+import { Decimal } from "../utils/decimal";
/**
* Pure interpolation logic — exported for unit testing.
@@ -19,22 +20,22 @@ import { useGameStore } from "../store";
* - Otherwise → interpolate forward, capped at one tick ahead of actual.
*/
export function interpolateTd(
- prev: number,
- actual: number,
- tdPerSecond: number,
+ prev: Decimal,
+ actual: Decimal,
+ tdPerSecond: Decimal,
elapsedSeconds: number,
-): number {
- if (tdPerSecond <= 0) return actual;
+): Decimal {
+ if (tdPerSecond.lte(0)) return actual;
// Snap when display has drifted more than 1.5 ticks ahead (e.g. purchase).
- if (prev - actual > tdPerSecond * 1.5) return actual;
+ if (prev.minus(actual).gt(tdPerSecond.mul(1.5))) return actual;
// Snap when actual jumped far ahead (e.g. offline progress load).
- if (actual - prev > tdPerSecond * 2) return actual;
+ if (actual.minus(prev).gt(tdPerSecond.mul(2))) return actual;
- const next = prev + tdPerSecond * elapsedSeconds;
+ const next = prev.plus(tdPerSecond.mul(elapsedSeconds));
// Cap at one tick ahead of actual to prevent visual runaway.
- return Math.min(next, actual + tdPerSecond);
+ return Decimal.min(next, actual.plus(tdPerSecond));
}
/**
@@ -44,8 +45,8 @@ export function interpolateTd(
* The authoritative value (from Zustand) remains the source of truth; this
* hook only affects the *display*.
*/
-export function useInterpolatedTd(): number {
- const [displayTd, setDisplayTd] = useState(
+export function useInterpolatedTd(): Decimal {
+ const [displayTd, setDisplayTd] = useState(
() => useGameStore.getState().trainingData,
);
const rafRef = useRef(0);
diff --git a/src/store/gameStore.test.ts b/src/store/gameStore.test.ts
index 360ca69..ce1a857 100644
--- a/src/store/gameStore.test.ts
+++ b/src/store/gameStore.test.ts
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { UPGRADES } from "../data/upgrades";
import { COMBO_THRESHOLD } from "../engine/clickEngine";
import { getUpgradeCost } from "../engine/upgradeEngine";
+import { D } from "../utils/decimal";
import { initialGameState, useGameStore } from "./gameStore";
beforeEach(() => {
@@ -17,9 +18,9 @@ describe("gameStore", () => {
describe("initial state", () => {
it("has correct default values", () => {
const state = useGameStore.getState();
- expect(state.trainingData).toBe(0);
+ expect(state.trainingData.toNumber()).toBe(0);
expect(state.totalClicks).toBe(0);
- expect(state.totalTdEarned).toBe(0);
+ expect(state.totalTdEarned.toNumber()).toBe(0);
expect(state.evolutionStage).toBe(0);
expect(state.lastSaved).toBe(0);
});
@@ -33,7 +34,7 @@ describe("gameStore", () => {
describe("clickFeed", () => {
it("increments trainingData by 1", () => {
useGameStore.getState().clickFeed();
- expect(useGameStore.getState().trainingData).toBe(1);
+ expect(useGameStore.getState().trainingData.toNumber()).toBe(1);
});
it("increments totalClicks by 1", () => {
@@ -43,7 +44,7 @@ describe("gameStore", () => {
it("increments totalTdEarned by 1", () => {
useGameStore.getState().clickFeed();
- expect(useGameStore.getState().totalTdEarned).toBe(1);
+ expect(useGameStore.getState().totalTdEarned.toNumber()).toBe(1);
});
it("updates lastSaved timestamp", () => {
@@ -67,9 +68,9 @@ describe("gameStore", () => {
useGameStore.getState().clickFeed();
const state = useGameStore.getState();
- expect(state.trainingData).toBe(3);
+ expect(state.trainingData.toNumber()).toBe(3);
expect(state.totalClicks).toBe(3);
- expect(state.totalTdEarned).toBe(3);
+ expect(state.totalTdEarned.toNumber()).toBe(3);
});
it("awards floor(1 × comboMultiplier) TD per click when combo is active", () => {
@@ -89,20 +90,20 @@ describe("gameStore", () => {
useGameStore.getState().clickFeed();
- expect(useGameStore.getState().trainingData).toBe(2);
- expect(useGameStore.getState().totalTdEarned).toBe(2);
+ expect(useGameStore.getState().trainingData.toNumber()).toBe(2);
+ expect(useGameStore.getState().totalTdEarned.toNumber()).toBe(2);
});
});
describe("addTrainingData", () => {
it("adds the specified amount to trainingData", () => {
useGameStore.getState().addTrainingData(100);
- expect(useGameStore.getState().trainingData).toBe(100);
+ expect(useGameStore.getState().trainingData.toNumber()).toBe(100);
});
it("adds the amount to totalTdEarned", () => {
useGameStore.getState().addTrainingData(100);
- expect(useGameStore.getState().totalTdEarned).toBe(100);
+ expect(useGameStore.getState().totalTdEarned.toNumber()).toBe(100);
});
it("does not affect totalClicks", () => {
@@ -122,13 +123,13 @@ describe("gameStore", () => {
it("accumulates with existing trainingData", () => {
useGameStore.getState().addTrainingData(10);
useGameStore.getState().addTrainingData(20);
- expect(useGameStore.getState().trainingData).toBe(30);
+ expect(useGameStore.getState().trainingData.toNumber()).toBe(30);
});
it("works with fractional amounts", () => {
useGameStore.getState().addTrainingData(0.5);
useGameStore.getState().addTrainingData(0.3);
- expect(useGameStore.getState().trainingData).toBeCloseTo(0.8);
+ expect(useGameStore.getState().trainingData.toNumber()).toBeCloseTo(0.8);
});
it("combines correctly with clickFeed", () => {
@@ -141,9 +142,9 @@ describe("gameStore", () => {
useGameStore.getState().clickFeed();
useGameStore.getState().addTrainingData(100);
const state = useGameStore.getState();
- expect(state.trainingData).toBe(102);
+ expect(state.trainingData.toNumber()).toBe(102);
expect(state.totalClicks).toBe(2);
- expect(state.totalTdEarned).toBe(102);
+ expect(state.totalTdEarned.toNumber()).toBe(102);
});
});
@@ -152,40 +153,40 @@ describe("gameStore", () => {
const baseCost = firstUpgrade.baseCost;
it("deducts cost and increments owned count", () => {
- useGameStore.setState({ trainingData: baseCost });
+ useGameStore.setState({ trainingData: D(baseCost) });
useGameStore.getState().purchaseUpgrade(firstUpgrade.id);
const state = useGameStore.getState();
- expect(state.trainingData).toBe(0);
+ expect(state.trainingData.toNumber()).toBe(0);
expect(state.upgradeOwned[firstUpgrade.id]).toBe(1);
});
it("no-ops when player cannot afford", () => {
- useGameStore.setState({ trainingData: baseCost - 1 });
+ useGameStore.setState({ trainingData: D(baseCost - 1) });
useGameStore.getState().purchaseUpgrade(firstUpgrade.id);
const state = useGameStore.getState();
- expect(state.trainingData).toBe(baseCost - 1);
+ expect(state.trainingData.toNumber()).toBe(baseCost - 1);
expect(state.upgradeOwned[firstUpgrade.id]).toBeUndefined();
});
it("scales cost after purchase", () => {
- const totalNeeded = baseCost + getUpgradeCost(firstUpgrade, 1);
+ const totalNeeded = D(baseCost).add(getUpgradeCost(firstUpgrade, 1));
useGameStore.setState({ trainingData: totalNeeded });
useGameStore.getState().purchaseUpgrade(firstUpgrade.id);
useGameStore.getState().purchaseUpgrade(firstUpgrade.id);
const state = useGameStore.getState();
expect(state.upgradeOwned[firstUpgrade.id]).toBe(2);
- expect(state.trainingData).toBe(0);
+ expect(state.trainingData.toNumber()).toBe(0);
});
it("no-ops for unknown upgrade id", () => {
- useGameStore.setState({ trainingData: 999_999 });
+ useGameStore.setState({ trainingData: D(999_999) });
useGameStore.getState().purchaseUpgrade("nonexistent");
const state = useGameStore.getState();
- expect(state.trainingData).toBe(999_999);
+ expect(state.trainingData.toNumber()).toBe(999_999);
});
it("updates lastSaved on purchase", () => {
- useGameStore.setState({ trainingData: baseCost });
+ useGameStore.setState({ trainingData: D(baseCost) });
const before = Date.now();
useGameStore.getState().purchaseUpgrade(firstUpgrade.id);
const after = Date.now();
@@ -197,20 +198,23 @@ describe("gameStore", () => {
it("allows purchasing different upgrades independently", () => {
const secondUpgrade = UPGRADES[1];
useGameStore.setState({
- trainingData: firstUpgrade.baseCost + secondUpgrade.baseCost,
+ trainingData: D(firstUpgrade.baseCost + secondUpgrade.baseCost),
});
useGameStore.getState().purchaseUpgrade(firstUpgrade.id);
useGameStore.getState().purchaseUpgrade(secondUpgrade.id);
const state = useGameStore.getState();
expect(state.upgradeOwned[firstUpgrade.id]).toBe(1);
expect(state.upgradeOwned[secondUpgrade.id]).toBe(1);
- expect(state.trainingData).toBe(0);
+ expect(state.trainingData.toNumber()).toBe(0);
});
it("does not change totalTdEarned", () => {
- useGameStore.setState({ trainingData: baseCost, totalTdEarned: 50 });
+ useGameStore.setState({
+ trainingData: D(baseCost),
+ totalTdEarned: D(50),
+ });
useGameStore.getState().purchaseUpgrade(firstUpgrade.id);
- expect(useGameStore.getState().totalTdEarned).toBe(50);
+ expect(useGameStore.getState().totalTdEarned.toNumber()).toBe(50);
});
});
@@ -261,7 +265,7 @@ describe("gameStore", () => {
it("purchaseUpgrade sets mood to Excited", () => {
const firstUpgrade = UPGRADES[0];
- useGameStore.setState({ trainingData: firstUpgrade.baseCost });
+ useGameStore.setState({ trainingData: D(firstUpgrade.baseCost) });
useGameStore.getState().purchaseUpgrade(firstUpgrade.id);
expect(useGameStore.getState().mood).toBe("Excited");
});
@@ -269,7 +273,7 @@ describe("gameStore", () => {
it("purchaseUpgrade resets moodChangedAt", () => {
const firstUpgrade = UPGRADES[0];
useGameStore.setState({
- trainingData: firstUpgrade.baseCost,
+ trainingData: D(firstUpgrade.baseCost),
moodChangedAt: 1000,
});
const before = Date.now();
@@ -281,7 +285,7 @@ describe("gameStore", () => {
});
it("failed purchase does not change mood", () => {
- useGameStore.setState({ trainingData: 0, mood: "Sad" });
+ useGameStore.setState({ trainingData: D(0), mood: "Sad" });
useGameStore.getState().purchaseUpgrade(UPGRADES[0].id);
expect(useGameStore.getState().mood).toBe("Sad");
});
@@ -304,7 +308,7 @@ describe("gameStore", () => {
});
it("evolves via clickFeed accumulation", () => {
- useGameStore.setState({ totalTdEarned: 99 });
+ useGameStore.setState({ totalTdEarned: D(99) });
useGameStore.getState().clickFeed();
expect(useGameStore.getState().evolutionStage).toBe(1);
});
@@ -359,7 +363,7 @@ describe("gameStore", () => {
it("performRebirth awards tokens and increments balance", () => {
useGameStore.setState({
- totalTdEarned: 20_000_000, // floor(sqrt(20_000_000 / 5_000_000)) = floor(sqrt(4)) = 2 tokens
+ totalTdEarned: D(20_000_000), // floor(sqrt(20_000_000 / 5_000_000)) = floor(sqrt(4)) = 2 tokens
evolutionStage: 5,
prestigeTokenBalance: 0,
wisdomTokens: 0,
@@ -372,7 +376,7 @@ describe("gameStore", () => {
it("performRebirth preserves prestige upgrades", () => {
useGameStore.setState({
- totalTdEarned: 400_000,
+ totalTdEarned: D(400_000),
evolutionStage: 5,
prestigeUpgrades: { "click-mastery": 3 },
});
@@ -394,7 +398,7 @@ describe("gameStore", () => {
it("resets crossedMilestones on rebirth", () => {
// Set up a state eligible for rebirth (stage 5) with some milestones
useGameStore.setState({
- totalTdEarned: 1_000_000,
+ totalTdEarned: D(1_000_000),
evolutionStage: 5,
crossedMilestones: [1_000, 10_000, 100_000, 1_000_000],
});
@@ -410,7 +414,7 @@ describe("gameStore", () => {
it("performRebirth sets activeChallengeId for next run", () => {
useGameStore.setState({
- totalTdEarned: 2_000_000,
+ totalTdEarned: D(2_000_000),
evolutionStage: 5,
});
useGameStore.getState().performRebirth(undefined, "click-only");
@@ -419,7 +423,7 @@ describe("gameStore", () => {
it("performRebirth clears challenge when none selected", () => {
useGameStore.setState({
- totalTdEarned: 2_000_000,
+ totalTdEarned: D(2_000_000),
evolutionStage: 5,
activeChallengeId: "click-only",
});
@@ -430,7 +434,7 @@ describe("gameStore", () => {
it("awards 2x tokens when challenge is completed", () => {
// click-only challenge, need stage >= 3
useGameStore.setState({
- totalTdEarned: 20_000_000, // base = floor(sqrt(20_000_000 / 5_000_000)) = floor(sqrt(4)) = 2
+ totalTdEarned: D(20_000_000), // base = floor(sqrt(20_000_000 / 5_000_000)) = floor(sqrt(4)) = 2
evolutionStage: 5,
activeChallengeId: "click-only",
runStart: Date.now() - 1000,
@@ -444,7 +448,7 @@ describe("gameStore", () => {
it("does not award bonus when challenge is not completed", () => {
// no-prestige needs stage 5, give stage 4 only
useGameStore.setState({
- totalTdEarned: 20_000_000, // floor(sqrt(20_000_000 / 5_000_000)) = floor(sqrt(4)) = 2
+ totalTdEarned: D(20_000_000), // floor(sqrt(20_000_000 / 5_000_000)) = floor(sqrt(4)) = 2
evolutionStage: 4,
activeChallengeId: "no-prestige",
runStart: Date.now() - 1000,
@@ -457,19 +461,19 @@ describe("gameStore", () => {
it("no-prestige challenge disables quick-start for next run", () => {
useGameStore.setState({
- totalTdEarned: 2_000_000,
+ totalTdEarned: D(2_000_000),
evolutionStage: 5,
prestigeUpgrades: { "quick-start": 2 },
});
useGameStore.getState().performRebirth(undefined, "no-prestige");
const state = useGameStore.getState();
- expect(state.trainingData).toBe(0);
+ expect(state.trainingData.toNumber()).toBe(0);
expect(state.activeChallengeId).toBe("no-prestige");
});
it("resets upgradeOwned to {} regardless of species-memory prestige", () => {
useGameStore.setState({
- totalTdEarned: 2_000_000,
+ totalTdEarned: D(2_000_000),
evolutionStage: 5,
prestigeUpgrades: { "species-memory": 2 },
upgradeOwned: { "neural-notepad": 10, "data-hamster-wheel": 5 },
@@ -483,7 +487,7 @@ describe("gameStore", () => {
describe("rebirth reset", () => {
it("resets upgradeOwned to {} after rebirth", () => {
useGameStore.setState({
- totalTdEarned: 2_000_000,
+ totalTdEarned: D(2_000_000),
evolutionStage: 5,
upgradeOwned: { "neural-notepad": 50, "quantum-processor": 100 },
});
@@ -493,7 +497,7 @@ describe("gameStore", () => {
it("resets upgradeOwned to {} even with species-memory prestige active", () => {
useGameStore.setState({
- totalTdEarned: 2_000_000,
+ totalTdEarned: D(2_000_000),
evolutionStage: 5,
prestigeUpgrades: { "species-memory": 5 },
upgradeOwned: {
@@ -508,7 +512,7 @@ describe("gameStore", () => {
it("resets evolutionStage to 0 after rebirth with no quick-start", () => {
useGameStore.setState({
- totalTdEarned: 10_000_000,
+ totalTdEarned: D(10_000_000),
evolutionStage: 4,
});
useGameStore.getState().performRebirth();
@@ -517,16 +521,16 @@ describe("gameStore", () => {
it("resets totalTdEarned to 0 after rebirth with no quick-start", () => {
useGameStore.setState({
- totalTdEarned: 5_000_000,
+ totalTdEarned: D(5_000_000),
evolutionStage: 4,
});
useGameStore.getState().performRebirth();
- expect(useGameStore.getState().totalTdEarned).toBe(0);
+ expect(useGameStore.getState().totalTdEarned.toNumber()).toBe(0);
});
it("preserves prestigeUpgrades (Wisdom bonuses) after rebirth", () => {
useGameStore.setState({
- totalTdEarned: 2_000_000,
+ totalTdEarned: D(2_000_000),
evolutionStage: 5,
prestigeUpgrades: { "idle-boost": 3, "click-mastery": 5 },
});
@@ -540,13 +544,13 @@ describe("gameStore", () => {
it("sets evolutionStage based on quickStartTd when quick-start prestige active", () => {
// quick-start level 2 = 10_000 TD; getEvolutionStage(10_000) = stage 2 (unlockAt 5_000)
useGameStore.setState({
- totalTdEarned: 2_000_000,
+ totalTdEarned: D(2_000_000),
evolutionStage: 5,
prestigeUpgrades: { "quick-start": 2 },
});
useGameStore.getState().performRebirth();
const state = useGameStore.getState();
- expect(state.totalTdEarned).toBe(10_000);
+ expect(state.totalTdEarned.toNumber()).toBe(10_000);
expect(state.evolutionStage).toBe(2);
});
});
diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts
index bda3292..4627cf8 100644
--- a/src/store/gameStore.ts
+++ b/src/store/gameStore.ts
@@ -1,3 +1,4 @@
+import type { DecimalSource } from "break_infinity.js";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { BOOSTERS } from "../data/boosters";
@@ -29,11 +30,12 @@ import {
getTotalTdPerSecond,
getUpgradeCost,
} from "../engine/upgradeEngine";
+import { D, Decimal, toDecimal } from "../utils/decimal";
export interface GameState {
- trainingData: number;
+ trainingData: Decimal;
totalClicks: number;
- totalTdEarned: number;
+ totalTdEarned: Decimal;
evolutionStage: number;
lastSaved: number;
upgradeOwned: Record;
@@ -66,12 +68,12 @@ export interface GameState {
crossedMilestones: number[];
// Per-run stats — reset on rebirth
runStart: number;
- peakTdPerSecond: number;
+ peakTdPerSecond: Decimal;
peakGeneratorsOwned: number;
// Cumulative lifetime stats — persist across rebirths
- lifetimeTdEarned: number;
- lifetimePeakTdPerSecond: number;
- lifetimeBestRunTd: number;
+ lifetimeTdEarned: Decimal;
+ lifetimePeakTdPerSecond: Decimal;
+ lifetimeBestRunTd: Decimal;
lifetimeWisdomEarned: number;
// Challenge run state — resets on rebirth
activeChallengeId: string | null;
@@ -79,7 +81,7 @@ export interface GameState {
interface GameActions {
clickFeed: () => void;
- addTrainingData: (amount: number) => void;
+ addTrainingData: (amount: DecimalSource) => void;
purchaseUpgrade: (id: string) => void;
purchaseBulkUpgrade: (id: string, count: number) => void;
purchaseBooster: (id: string) => void;
@@ -95,16 +97,19 @@ interface GameActions {
unlockEasterEgg: (id: string) => void;
incrementTimePlayed: (seconds: number) => void;
crossMilestones: (thresholds: number[]) => void;
- updatePeakStats: (tdPerSecond: number, generatorsOwned: number) => void;
+ updatePeakStats: (
+ tdPerSecond: DecimalSource,
+ generatorsOwned: number,
+ ) => void;
awardDailyWisdomTokens: (amount: number) => void;
}
export type GameStore = GameState & GameActions;
export const initialGameState: GameState = {
- trainingData: 0,
+ trainingData: D(0),
totalClicks: 0,
- totalTdEarned: 0,
+ totalTdEarned: D(0),
evolutionStage: 0,
lastSaved: 0,
upgradeOwned: {},
@@ -128,11 +133,11 @@ export const initialGameState: GameState = {
totalTimePlayed: 0,
crossedMilestones: [],
runStart: 0,
- peakTdPerSecond: 0,
+ peakTdPerSecond: D(0),
peakGeneratorsOwned: 0,
- lifetimeTdEarned: 0,
- lifetimePeakTdPerSecond: 0,
- lifetimeBestRunTd: 0,
+ lifetimeTdEarned: D(0),
+ lifetimePeakTdPerSecond: D(0),
+ lifetimeBestRunTd: D(0),
lifetimeWisdomEarned: 0,
activeChallengeId: null,
};
@@ -142,6 +147,19 @@ function pLevel(prestigeUpgrades: Record, id: string): number {
return prestigeUpgrades[id] ?? 0;
}
+/**
+ * List of GameState keys that are stored as Decimal.
+ * Used for serialization/deserialization in the persist middleware.
+ */
+const DECIMAL_KEYS: ReadonlySet = new Set([
+ "trainingData",
+ "totalTdEarned",
+ "peakTdPerSecond",
+ "lifetimeTdEarned",
+ "lifetimePeakTdPerSecond",
+ "lifetimeBestRunTd",
+]);
+
export const useGameStore = create()(
persist(
(set) => ({
@@ -186,12 +204,12 @@ export const useGameStore = create()(
clickMastery,
speciesBonus.clickPower,
);
- const newTotalTdEarned = state.totalTdEarned + clickPower;
+ const newTotalTdEarned = state.totalTdEarned.add(clickPower);
const evoMultiplier = getEvolutionThresholdMultiplier(
pLevel(effectivePrestige, "evolution-accelerator"),
);
return {
- trainingData: state.trainingData + clickPower,
+ trainingData: state.trainingData.add(clickPower),
totalClicks: state.totalClicks + 1,
totalTdEarned: newTotalTdEarned,
evolutionStage: getEvolutionStage(newTotalTdEarned, evoMultiplier),
@@ -202,7 +220,8 @@ export const useGameStore = create()(
}),
addTrainingData: (amount) =>
set((state) => {
- const newTotalTdEarned = state.totalTdEarned + amount;
+ const amountD = D(amount);
+ const newTotalTdEarned = state.totalTdEarned.add(amountD);
const ep =
state.activeChallengeId === "no-prestige"
? {}
@@ -211,7 +230,7 @@ export const useGameStore = create()(
pLevel(ep, "evolution-accelerator"),
);
return {
- trainingData: state.trainingData + amount,
+ trainingData: state.trainingData.add(amountD),
totalTdEarned: newTotalTdEarned,
evolutionStage: getEvolutionStage(newTotalTdEarned, evoMultiplier),
lastSaved: Date.now(),
@@ -232,10 +251,10 @@ export const useGameStore = create()(
);
const cost = getUpgradeCost(upgrade, owned, costMultiplier);
- if (state.trainingData < cost) return state;
+ if (state.trainingData.lt(cost)) return state;
return {
- trainingData: state.trainingData - cost,
+ trainingData: state.trainingData.sub(cost),
upgradeOwned: { ...state.upgradeOwned, [id]: owned + 1 },
lastSaved: Date.now(),
mood: "Excited" as Mood,
@@ -258,10 +277,10 @@ export const useGameStore = create()(
);
const cost = getBulkCost(upgrade, owned, count, costMultiplier);
- if (state.trainingData < cost) return state;
+ if (state.trainingData.lt(cost)) return state;
return {
- trainingData: state.trainingData - cost,
+ trainingData: state.trainingData.sub(cost),
upgradeOwned: { ...state.upgradeOwned, [id]: owned + count },
lastSaved: Date.now(),
mood: "Excited" as Mood,
@@ -275,10 +294,10 @@ export const useGameStore = create()(
if (state.boostersPurchased.includes(id)) return state;
if (state.evolutionStage < booster.unlockStage) return state;
- if (state.trainingData < booster.cost) return state;
+ if (state.trainingData.lt(booster.cost)) return state;
return {
- trainingData: state.trainingData - booster.cost,
+ trainingData: state.trainingData.sub(booster.cost),
boostersPurchased: [...state.boostersPurchased, id],
lastSaved: Date.now(),
mood: "Excited" as Mood,
@@ -291,11 +310,11 @@ export const useGameStore = create()(
if (!upgrade) return state;
if (state.clickUpgradesPurchased.includes(id)) return state;
- if (state.trainingData < upgrade.cost) return state;
+ if (state.trainingData.lt(upgrade.cost)) return state;
if (state.evolutionStage < upgrade.unlockStage) return state;
return {
- trainingData: state.trainingData - upgrade.cost,
+ trainingData: state.trainingData.sub(upgrade.cost),
clickUpgradesPurchased: [...state.clickUpgradesPurchased, id],
lastSaved: Date.now(),
mood: "Excited" as Mood,
@@ -346,12 +365,12 @@ export const useGameStore = create()(
})),
updatePeakStats: (tdPerSecond, generatorsOwned) =>
set((state) => ({
- peakTdPerSecond: Math.max(state.peakTdPerSecond, tdPerSecond),
+ peakTdPerSecond: Decimal.max(state.peakTdPerSecond, tdPerSecond),
peakGeneratorsOwned: Math.max(
state.peakGeneratorsOwned,
generatorsOwned,
),
- lifetimePeakTdPerSecond: Math.max(
+ lifetimePeakTdPerSecond: Decimal.max(
state.lifetimePeakTdPerSecond,
tdPerSecond,
),
@@ -433,9 +452,9 @@ export const useGameStore = create()(
return {
// Reset progression
- trainingData: quickStartTd,
+ trainingData: D(quickStartTd),
totalClicks: 0,
- totalTdEarned: quickStartTd,
+ totalTdEarned: D(quickStartTd),
evolutionStage:
quickStartTd > 0 ? getEvolutionStage(quickStartTd) : 0,
upgradeOwned: {},
@@ -452,7 +471,7 @@ export const useGameStore = create()(
crossedMilestones: [],
// Reset per-run stats
runStart: now,
- peakTdPerSecond: 0,
+ peakTdPerSecond: D(0),
peakGeneratorsOwned: 0,
// Persist rebirth rewards
wisdomTokens: newWisdomTokens,
@@ -461,12 +480,12 @@ export const useGameStore = create()(
currentSpecies: nextSpecies,
unlockedSpecies: newUnlocked,
// Accumulate lifetime stats
- lifetimeTdEarned: state.lifetimeTdEarned + runTd,
- lifetimePeakTdPerSecond: Math.max(
+ lifetimeTdEarned: state.lifetimeTdEarned.add(runTd),
+ lifetimePeakTdPerSecond: Decimal.max(
state.lifetimePeakTdPerSecond,
state.peakTdPerSecond,
),
- lifetimeBestRunTd: Math.max(state.lifetimeBestRunTd, runTd),
+ lifetimeBestRunTd: Decimal.max(state.lifetimeBestRunTd, runTd),
lifetimeWisdomEarned: state.lifetimeWisdomEarned + earned,
// Challenge for next run (null = normal)
activeChallengeId: challengeId ?? null,
@@ -476,15 +495,26 @@ export const useGameStore = create()(
{
name: "glorp-game-state",
merge: (persisted, current) => {
- const saved = persisted as Partial | undefined;
+ const saved = persisted as Partial> | undefined;
if (!saved) return current;
+
+ // Convert Decimal fields from persisted strings/numbers back to Decimal
+ const merged = { ...current, ...(saved as Partial) };
+ for (const key of DECIMAL_KEYS) {
+ const val = saved[key];
+ if (val !== undefined) {
+ (merged as Record)[key] = toDecimal(
+ val as string | number | null,
+ );
+ }
+ }
+
// Migrate old saves: convert wisdomTokens to spendable balance
- const merged = { ...current, ...saved };
if (saved.prestigeUpgrades === undefined) {
merged.prestigeUpgrades = {};
}
if (saved.prestigeTokenBalance === undefined) {
- merged.prestigeTokenBalance = saved.wisdomTokens ?? 0;
+ merged.prestigeTokenBalance = (saved.wisdomTokens as number) ?? 0;
}
if (saved.hasOpenedPrestigeShop === undefined) {
merged.hasOpenedPrestigeShop = false;
diff --git a/src/utils/decimal.ts b/src/utils/decimal.ts
new file mode 100644
index 0000000..824369f
--- /dev/null
+++ b/src/utils/decimal.ts
@@ -0,0 +1,56 @@
+/**
+ * Centralized Decimal re-export and helper utilities for break_infinity.js.
+ *
+ * All game code should import Decimal and DecimalSource from this module
+ * rather than directly from break_infinity.js. This gives us a single place
+ * to add helpers and keeps the dependency isolated.
+ */
+
+import type { DecimalSource } from "break_infinity.js";
+import Decimal from "break_infinity.js";
+
+export { Decimal };
+export type { DecimalSource };
+
+/** Shorthand constructor — `D(value)` is equivalent to `new Decimal(value)`. */
+export function D(value: DecimalSource): Decimal {
+ return new Decimal(value);
+}
+
+/** Decimal zero constant (immutable — never mutate). */
+export const ZERO = D(0);
+/** Decimal one constant (immutable — never mutate). */
+export const ONE = D(1);
+
+/**
+ * Convert a value to Decimal, treating null/undefined as zero.
+ * Useful when reading potentially-missing persisted state fields.
+ */
+export function toDecimal(value: DecimalSource | null | undefined): Decimal {
+ if (value === null || value === undefined) return D(0);
+ return D(value);
+}
+
+/**
+ * Serialize a Decimal to a JSON-safe number or string.
+ * Values within Number.MAX_SAFE_INTEGER are stored as numbers for
+ * backward-compatible saves; larger values are stored as strings.
+ */
+export function serializeDecimal(d: Decimal): number | string {
+ const n = d.toNumber();
+ if (Number.isFinite(n) && Math.abs(n) <= Number.MAX_SAFE_INTEGER) {
+ return n;
+ }
+ return d.toString();
+}
+
+/**
+ * Deserialize a value (number or string) back into a Decimal.
+ * Handles legacy saves that stored values as plain numbers.
+ */
+export function deserializeDecimal(
+ value: number | string | null | undefined,
+): Decimal {
+ if (value === null || value === undefined) return D(0);
+ return D(value);
+}
diff --git a/src/utils/formatNumber.ts b/src/utils/formatNumber.ts
index f302c8f..22d2506 100644
--- a/src/utils/formatNumber.ts
+++ b/src/utils/formatNumber.ts
@@ -1,31 +1,62 @@
+import type { DecimalSource } from "break_infinity.js";
+import { Decimal } from "./decimal";
+
/**
- * Format a number with locale-aware comma separators for full display.
+ * Format a number or Decimal with locale-aware comma separators for full display.
*
* - Values >= 1 → comma-separated integer (e.g. "1,234,567")
* - Values between 0 and 1 → 2 decimal places (e.g. "0.50")
* - Negative values are prefixed with "-" and formatted the same way.
* - Very large numbers display cleanly without exponential notation.
*/
-export function formatNumber(n: number): string {
- if (n < 0) {
- return `-${formatNumber(-n)}`;
+export function formatNumber(n: DecimalSource): string {
+ // For plain numbers within safe integer range, use native formatting directly
+ // to avoid break_infinity.js mantissa precision quirks on round values.
+ if (typeof n === "number") {
+ if (n < 0) return `-${formatNumber(-n)}`;
+ if (n === 0) return "0";
+ if (n > 0 && n < 1) return n.toFixed(2);
+ return new Intl.NumberFormat(undefined, {
+ maximumFractionDigits: 0,
+ }).format(Math.floor(n));
}
- if (n > 0 && n < 1) {
- return n.toFixed(2);
+ const d = new Decimal(n);
+
+ if (d.lt(0)) {
+ return `-${formatNumber(d.abs())}`;
+ }
+
+ if (d.eq(0)) return "0";
+
+ if (d.gt(0) && d.lt(1)) {
+ return d.toNumber().toFixed(2);
}
- if (n === 0) return "0";
+ // For values within safe integer range, convert to number for precise formatting
+ if (d.lt(Number.MAX_SAFE_INTEGER)) {
+ return new Intl.NumberFormat(undefined, {
+ maximumFractionDigits: 0,
+ }).format(Math.floor(d.toNumber()));
+ }
+
+ // For very large values, format manually with commas
+ const floored = d.floor();
+ const str = floored.toString();
+
+ // If in exponential notation, return as-is (break_infinity.js handles this)
+ if (str.includes("e") || str.includes("E")) {
+ return str;
+ }
- return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(
- Math.floor(n),
- );
+ // Add comma separators manually for large integers
+ return str.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/**
* Format a number with full comma-separated digits (e.g. "1,234,567,890").
* Alias for formatNumber.
*/
-export function formatNumberFull(n: number): string {
+export function formatNumberFull(n: DecimalSource): string {
return formatNumber(n);
}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 2cb6847..1379549 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1 +1,3 @@
+export type { DecimalSource } from "./decimal";
+export { D, Decimal, toDecimal } from "./decimal";
export { formatNumber, formatNumberFull } from "./formatNumber";
diff --git a/src/utils/saveManager.test.ts b/src/utils/saveManager.test.ts
index 306d1e6..4f96c94 100644
--- a/src/utils/saveManager.test.ts
+++ b/src/utils/saveManager.test.ts
@@ -2,6 +2,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GameState } from "../store/gameStore";
import { initialGameState, useGameStore } from "../store/gameStore";
+import { D } from "./decimal";
import {
applySave,
exportSave,
@@ -14,7 +15,8 @@ import {
validateSave,
} from "./saveManager";
-const validSave: GameState = {
+// Raw save data uses plain numbers for Decimal fields (migrateSave converts them)
+const validSave = {
trainingData: 100,
totalClicks: 50,
totalTdEarned: 200,
@@ -48,7 +50,7 @@ const validSave: GameState = {
lifetimeBestRunTd: 0,
lifetimeWisdomEarned: 0,
activeChallengeId: null,
-};
+} as unknown as GameState;
beforeEach(() => {
useGameStore.setState(initialGameState);
@@ -86,24 +88,24 @@ describe("applySave", () => {
it("applies the save to the game store", () => {
applySave(validSave);
const state = useGameStore.getState();
- expect(state.trainingData).toBe(100);
+ expect(state.trainingData.toNumber()).toBe(100);
expect(state.totalClicks).toBe(50);
expect(state.rebirthCount).toBe(1);
expect(state.unlockedAchievements).toEqual(["first-click"]);
});
it("overwrites existing store values", () => {
- useGameStore.setState({ trainingData: 9999 });
+ useGameStore.setState({ trainingData: D(9999) });
applySave(validSave);
- expect(useGameStore.getState().trainingData).toBe(100);
+ expect(useGameStore.getState().trainingData.toNumber()).toBe(100);
});
});
describe("resetGame", () => {
it("resets trainingData to 0", () => {
- useGameStore.setState({ trainingData: 9999 });
+ useGameStore.setState({ trainingData: D(9999) });
resetGame();
- expect(useGameStore.getState().trainingData).toBe(0);
+ expect(useGameStore.getState().trainingData.toNumber()).toBe(0);
});
it("resets totalClicks to 0", () => {
@@ -208,7 +210,7 @@ describe("parseSaveFile", () => {
const json = JSON.stringify(validSave);
const file = new File([json], "save.json", { type: "application/json" });
const result = await parseSaveFile(file);
- expect(result.trainingData).toBe(100);
+ expect(result.trainingData.toNumber()).toBe(100);
expect(result.rebirthCount).toBe(1);
});
@@ -233,7 +235,7 @@ describe("exportSaveToClipboard", () => {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
writable: true,
});
- useGameStore.setState({ ...initialGameState, trainingData: 42 });
+ useGameStore.setState({ ...initialGameState, trainingData: D(42) });
});
it("copies a JSON string to clipboard", async () => {
@@ -258,7 +260,7 @@ describe("exportSaveToClipboard", () => {
const written = (navigator.clipboard.writeText as ReturnType)
.mock.calls[0][0] as string;
const imported = importSaveFromString(written);
- expect(imported.trainingData).toBe(42);
+ expect(imported.trainingData.toNumber()).toBe(42);
});
});
@@ -272,7 +274,7 @@ describe("importSaveFromString", () => {
it("returns a valid GameState for a correct envelope", () => {
const result = importSaveFromString(makeEnvelope(validSave));
- expect(result.trainingData).toBe(100);
+ expect(result.trainingData.toNumber()).toBe(100);
expect(result.rebirthCount).toBe(1);
});
@@ -305,12 +307,12 @@ describe("importSaveFromString", () => {
});
it("does not modify game state on failure", () => {
- useGameStore.setState({ trainingData: 9999 });
+ useGameStore.setState({ trainingData: D(9999) });
try {
importSaveFromString("garbage");
} catch {
// expected
}
- expect(useGameStore.getState().trainingData).toBe(9999);
+ expect(useGameStore.getState().trainingData.toNumber()).toBe(9999);
});
});
diff --git a/src/utils/saveManager.ts b/src/utils/saveManager.ts
index fa1f50f..a27d8e6 100644
--- a/src/utils/saveManager.ts
+++ b/src/utils/saveManager.ts
@@ -1,5 +1,6 @@
import type { GameState } from "../store/gameStore";
import { initialGameState, useGameStore } from "../store/gameStore";
+import { toDecimal } from "./decimal";
const REQUIRED_KEYS: (keyof GameState)[] = [
"trainingData",
@@ -27,6 +28,30 @@ export function validateSave(data: unknown): data is GameState {
return REQUIRED_KEYS.every((key) => key in (data as object));
}
+/** Keys in GameState that should be Decimal objects. */
+const DECIMAL_KEYS: ReadonlySet = new Set([
+ "trainingData",
+ "totalTdEarned",
+ "peakTdPerSecond",
+ "lifetimeTdEarned",
+ "lifetimePeakTdPerSecond",
+ "lifetimeBestRunTd",
+]);
+
+/**
+ * Ensure all Decimal-typed fields are actual Decimal instances.
+ * Handles legacy saves where these were plain numbers.
+ */
+function hydrateDecimals(data: GameState): GameState {
+ const record = data as unknown as Record;
+ for (const key of DECIMAL_KEYS) {
+ if (key in record) {
+ record[key] = toDecimal(record[key] as string | number | null);
+ }
+ }
+ return data;
+}
+
/**
* Migrate old saves that lack prestige fields.
* Old saves have `wisdomTokens` but no `prestigeTokenBalance`.
@@ -34,21 +59,24 @@ export function validateSave(data: unknown): data is GameState {
*/
export function migrateSave(data: GameState): GameState {
const record = data as unknown as Record;
+ let migrated: GameState;
if (!("prestigeTokenBalance" in record)) {
- return {
+ migrated = {
...data,
prestigeUpgrades: {},
prestigeTokenBalance: data.wisdomTokens ?? 0,
hasOpenedPrestigeShop: false,
};
+ } else {
+ // Ensure defaults for any missing prestige fields
+ migrated = {
+ ...data,
+ prestigeUpgrades: data.prestigeUpgrades ?? {},
+ prestigeTokenBalance: data.prestigeTokenBalance ?? 0,
+ hasOpenedPrestigeShop: data.hasOpenedPrestigeShop ?? false,
+ };
}
- // Ensure defaults for any missing prestige fields
- return {
- ...data,
- prestigeUpgrades: data.prestigeUpgrades ?? {},
- prestigeTokenBalance: data.prestigeTokenBalance ?? 0,
- hasOpenedPrestigeShop: data.hasOpenedPrestigeShop ?? false,
- };
+ return hydrateDecimals(migrated);
}
export function exportSave(): void {