From 7e7a36bb7821a939234a042ba0c9bb1957747dde Mon Sep 17 00:00:00 2001
From: thepaperpilot <thepaperpilot@gmail.com>
Date: Sun, 2 Apr 2023 16:41:39 -0500
Subject: [PATCH] Add summedPurchases param for buy max utilities

---
 src/game/formulas/formulas.ts | 79 +++++++++++++++++++++++++++++-----
 tests/game/formulas.test.ts   | 81 +++++++++++++++++++++++++++++++----
 2 files changed, 141 insertions(+), 19 deletions(-)

diff --git a/src/game/formulas/formulas.ts b/src/game/formulas/formulas.ts
index 09f17b9..12ea545 100644
--- a/src/game/formulas/formulas.ts
+++ b/src/game/formulas/formulas.ts
@@ -1359,39 +1359,66 @@ export function printFormula(formula: FormulaSource): string {
  * @param formula The formula to use for calculating buy max from
  * @param resource The resource used when purchasing (is only read from)
  * @param spendResources Whether or not to count spent resources on each purchase or not. If true, costs will be approximated for performance, skewing towards fewer purchases
+ * @param summedPurchases How many of the most expensive purchases should be manually summed for better accuracy. If unspecified uses 10 when spending resources and 0 when not
  */
 export function calculateMaxAffordable(
     formula: InvertibleFormula,
     resource: Resource,
-    spendResources?: true
+    spendResources?: true,
+    summedPurchases?: number
 ): ComputedRef<DecimalSource>;
 export function calculateMaxAffordable(
     formula: InvertibleIntegralFormula,
     resource: Resource,
-    spendResources: Computable<boolean>
+    spendResources: Computable<boolean>,
+    summedPurchases?: number
 ): ComputedRef<DecimalSource>;
 export function calculateMaxAffordable(
     formula: InvertibleFormula,
     resource: Resource,
-    spendResources: Computable<boolean> = true
+    spendResources: Computable<boolean> = true,
+    summedPurchases?: number
 ) {
     const computedSpendResources = convertComputable(spendResources);
     return computed(() => {
+        let affordable;
         if (unref(computedSpendResources)) {
             if (!formula.isIntegrable() || !formula.isIntegralInvertible()) {
                 throw new Error(
                     "Cannot calculate max affordable of formula with non-invertible integral"
                 );
             }
-            return Decimal.floor(
+            affordable = Decimal.floor(
                 formula.invertIntegral(Decimal.add(resource.value, formula.evaluateIntegral()))
             ).sub(unref(formula.innermostVariable) ?? 0);
+            if (summedPurchases == null) {
+                summedPurchases = 10;
+            }
         } else {
             if (!formula.isInvertible()) {
                 throw new Error("Cannot calculate max affordable of non-invertible formula");
             }
-            return Decimal.floor(formula.invert(resource.value));
+            affordable = Decimal.floor(formula.invert(resource.value));
+            if (summedPurchases == null) {
+                summedPurchases = 0;
+            }
         }
+        if (summedPurchases > 0) {
+            affordable = affordable.sub(summedPurchases).clampMin(0);
+            let summedCost = calculateCost(formula, affordable, true, 0);
+            while (true) {
+                const nextCost = formula.evaluate(
+                    affordable.add(unref(formula.innermostVariable) ?? 0)
+                );
+                if (Decimal.add(summedCost, nextCost).lt(resource.value)) {
+                    affordable = affordable.add(1);
+                    summedCost = Decimal.add(summedCost, nextCost);
+                } else {
+                    break;
+                }
+            }
+        }
+        return affordable;
     });
 }
 
@@ -1400,26 +1427,56 @@ export function calculateMaxAffordable(
  * @param formula The formula to use for calculating buy max from
  * @param amountToBuy The amount of purchases to calculate the cost for
  * @param spendResources Whether or not to count spent resources on each purchase or not. If true, costs will be approximated for performance, skewing towards higher cost
+ * @param summedPurchases How many purchases to manually sum for improved accuracy. If not specified, defaults to 10 when spending resources and 0 when not
  */
 export function calculateCost(
     formula: InvertibleFormula,
     amountToBuy: DecimalSource,
-    spendResources?: true
+    spendResources?: true,
+    summedPurchases?: number
 ): DecimalSource;
 export function calculateCost(
     formula: InvertibleIntegralFormula,
     amountToBuy: DecimalSource,
-    spendResources: boolean
+    spendResources: boolean,
+    summedPurchases?: number
 ): DecimalSource;
 export function calculateCost(
     formula: InvertibleFormula,
     amountToBuy: DecimalSource,
-    spendResources = true
+    spendResources = true,
+    summedPurchases?: number
 ) {
-    const newValue = Decimal.add(amountToBuy, unref(formula.innermostVariable) ?? 0);
+    let newValue = Decimal.add(amountToBuy, unref(formula.innermostVariable) ?? 0);
     if (spendResources) {
-        return Decimal.sub(formula.evaluateIntegral(newValue), formula.evaluateIntegral());
+        const targetValue = newValue;
+        newValue = newValue
+            .sub(summedPurchases ?? 10)
+            .clampMin(unref(formula.innermostVariable) ?? 0);
+        let cost = Decimal.sub(formula.evaluateIntegral(newValue), formula.evaluateIntegral());
+        if (targetValue.gt(1e308)) {
+            // Too large of a number for summedPurchases to make a difference,
+            // just get the cost and multiply by summed purchases
+            return cost.add(Decimal.sub(targetValue, newValue).times(formula.evaluate(newValue)));
+        }
+        for (let i = newValue.toNumber(); i < targetValue.toNumber(); i++) {
+            cost = cost.add(formula.evaluate(i));
+        }
+        return cost;
     } else {
-        return formula.evaluate(newValue);
+        const targetValue = newValue;
+        newValue = newValue
+            .sub(summedPurchases ?? 0)
+            .clampMin(unref(formula.innermostVariable) ?? 0);
+        let cost = formula.evaluate(newValue);
+        if (targetValue.gt(1e308)) {
+            // Too large of a number for summedPurchases to make a difference,
+            // just get the cost and multiply by summed purchases
+            return Decimal.sub(targetValue, newValue).add(1).times(cost);
+        }
+        for (let i = newValue.toNumber(); i < targetValue.toNumber(); i++) {
+            cost = Decimal.add(cost, formula.evaluate(i));
+        }
+        return cost;
     }
 }
diff --git a/tests/game/formulas.test.ts b/tests/game/formulas.test.ts
index fbaa378..ed5adb2 100644
--- a/tests/game/formulas.test.ts
+++ b/tests/game/formulas.test.ts
@@ -1022,21 +1022,20 @@ describe("Custom Formulas", () => {
 describe("Buy Max", () => {
     let resource: Resource;
     beforeAll(() => {
-        resource = createResource(ref(1000));
+        resource = createResource(ref(100000));
     });
     describe("Without spending", () => {
         test("Throws on formula with non-invertible integral", () => {
             const maxAffordable = calculateMaxAffordable(Formula.neg(10), resource, false);
             expect(() => maxAffordable.value).toThrow();
         });
-        // https://www.desmos.com/calculator/7ffthe7wi8
         test("Calculates max affordable and cost correctly", () => {
             const variable = Formula.variable(0);
             const formula = Formula.pow(1.05, variable).times(100);
             const maxAffordable = calculateMaxAffordable(formula, resource, false);
-            expect(maxAffordable.value).compare_tolerance(47);
+            expect(maxAffordable.value).compare_tolerance(141);
             expect(calculateCost(formula, maxAffordable.value, false)).compare_tolerance(
-                Decimal.pow(1.05, 47).times(100)
+                Decimal.pow(1.05, 141).times(100)
             );
         });
     });
@@ -1045,11 +1044,11 @@ describe("Buy Max", () => {
             const maxAffordable = calculateMaxAffordable(Formula.abs(10), resource);
             expect(() => maxAffordable.value).toThrow();
         });
-        test("Calculates max affordable and cost correctly with 0 purchases", () => {
+        test("Estimates max affordable and cost correctly with 0 purchases", () => {
             const purchases = ref(0);
             const variable = Formula.variable(purchases);
             const formula = Formula.pow(1.05, variable).times(100);
-            const maxAffordable = calculateMaxAffordable(formula, resource);
+            const maxAffordable = calculateMaxAffordable(formula, resource, true, 0);
             let actualAffordable = 0;
             let summedCost = Decimal.dZero;
             while (true) {
@@ -1073,11 +1072,11 @@ describe("Buy Max", () => {
                 Decimal.sub(actualCost, calculatedCost).abs().div(actualCost).toNumber()
             ).toBeLessThan(0.1);
         });
-        test("Calculates max affordable and cost correctly with 1 purchase", () => {
+        test("Estimates max affordable and cost with 1 purchase", () => {
             const purchases = ref(1);
             const variable = Formula.variable(purchases);
             const formula = Formula.pow(1.05, variable).times(100);
-            const maxAffordable = calculateMaxAffordable(formula, resource);
+            const maxAffordable = calculateMaxAffordable(formula, resource, true, 0);
             let actualAffordable = 0;
             let summedCost = Decimal.dZero;
             while (true) {
@@ -1101,5 +1100,71 @@ describe("Buy Max", () => {
                 Decimal.sub(actualCost, calculatedCost).abs().div(actualCost).toNumber()
             ).toBeLessThan(0.1);
         });
+        test("Estimates max affordable and cost more accurately with summing last purchases", () => {
+            const purchases = ref(1);
+            const variable = Formula.variable(purchases);
+            const formula = Formula.pow(1.05, variable).times(100);
+            const maxAffordable = calculateMaxAffordable(formula, resource);
+            let actualAffordable = 0;
+            let summedCost = Decimal.dZero;
+            while (true) {
+                const nextCost = formula.evaluate(Decimal.add(actualAffordable, 1));
+                if (Decimal.add(summedCost, nextCost).lte(resource.value)) {
+                    actualAffordable++;
+                    summedCost = Decimal.add(summedCost, nextCost);
+                } else {
+                    break;
+                }
+            }
+            expect(maxAffordable.value).compare_tolerance(actualAffordable);
+
+            const actualCost = new Array(actualAffordable)
+                .fill(null)
+                .reduce((acc, _, i) => acc.add(formula.evaluate(i + 1)), new Decimal(0));
+            const calculatedCost = calculateCost(formula, maxAffordable.value);
+            // Since we're summing the last few purchases, this has a tighter deviation allowed
+            expect(
+                Decimal.sub(actualCost, calculatedCost).abs().div(actualCost).toNumber()
+            ).toBeLessThan(0.02);
+        });
+        test("Handles summing purchases when making few purchases", () => {
+            const purchases = ref(90);
+            const variable = Formula.variable(purchases);
+            const formula = Formula.pow(1.05, variable).times(100);
+            const maxAffordable = calculateMaxAffordable(formula, resource);
+            let actualAffordable = 0;
+            let summedCost = Decimal.dZero;
+            while (true) {
+                const nextCost = formula.evaluate(Decimal.add(actualAffordable, purchases.value));
+                if (Decimal.add(summedCost, nextCost).lte(resource.value)) {
+                    actualAffordable++;
+                    summedCost = Decimal.add(summedCost, nextCost);
+                } else {
+                    break;
+                }
+            }
+            expect(maxAffordable.value).compare_tolerance(actualAffordable);
+
+            const actualCost = new Array(actualAffordable)
+                .fill(null)
+                .reduce(
+                    (acc, _, i) => acc.add(formula.evaluate(i + purchases.value)),
+                    new Decimal(0)
+                );
+            const calculatedCost = calculateCost(formula, maxAffordable.value);
+            // Since we're summing all the purchases this should be equivalent
+            expect(calculatedCost).compare_tolerance(actualCost);
+        });
+        test("Handles summing purchases when over e308 purchases", () => {
+            resource.value = "1ee308";
+            const purchases = ref(0);
+            const variable = Formula.variable(purchases);
+            const formula = variable;
+            const maxAffordable = calculateMaxAffordable(formula, resource);
+            const calculatedCost = calculateCost(formula, maxAffordable.value);
+            expect(Decimal.isNaN(calculatedCost)).toBe(false);
+            expect(Decimal.isFinite(calculatedCost)).toBe(true);
+            resource.value = 100000;
+        });
     });
 });