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 {