diff --git a/src/game/formulas/formulas.ts b/src/game/formulas/formulas.ts index 030e604..09f17b9 100644 --- a/src/game/formulas/formulas.ts +++ b/src/game/formulas/formulas.ts @@ -32,6 +32,10 @@ function integrateVariable(this: GenericFormula) { return Formula.pow(this, 2).div(2); } +function integrateVariableInner(this: GenericFormula) { + return this; +} + /** * A class that can be used for cost/goal functions. It can be evaluated similar to a cost function, but also provides extra features for supported formulas. For example, a lot of math functions can be inverted. * Typically, the use of these extra features is to support cost/goal functions that have multiple levels purchased/completed at once efficiently. @@ -81,6 +85,7 @@ export default class Formula { internalVariables: 1, innermostVariable: variable, internalIntegrate: integrateVariable, + internalIntegrateInner: integrateVariableInner, applySubstitution: ops.passthrough as unknown as SubstitutionFunction }; } @@ -119,7 +124,8 @@ export default class Formula { }; } - private calculateConstantOfIntegration() { + /** Calculates C for the implementation of the integral formula for this formula. */ + calculateConstantOfIntegration() { // Calculate C based on the knowledge that at x=1, the integral should be the average between f(0) and f(1) const integral = this.getIntegralFormula().evaluate(1); const actualCost = Decimal.add(this.evaluate(0), this.evaluate(1)).div(2); @@ -189,10 +195,7 @@ export default class Formula { if (!this.isIntegrable()) { throw new Error("Cannot evaluate integral of formula without integral"); } - return Decimal.add( - this.getIntegralFormula().evaluate(variable), - this.calculateConstantOfIntegration() - ); + return this.getIntegralFormula().evaluate(variable); } /** @@ -212,7 +215,7 @@ export default class Formula { * @param stack For nested formulas, a stack of operations that occur outside the complex operation. */ getIntegralFormula(stack?: SubstitutionStack): GenericFormula { - if (this.integralFormula != null) { + if (this.integralFormula != null && stack == null) { return this.integralFormula; } if (stack == null) { @@ -245,6 +248,7 @@ export default class Formula { throw new Error("Cannot integrate formula without variable"); } } + return this.integralFormula; } else { // "Inner" part of the formula if (this.applySubstitution == null) { @@ -255,25 +259,19 @@ export default class Formula { this.applySubstitution!.call(this, variable, ...this.inputs) ); if (this.internalIntegrateInner) { - this.integralFormula = this.internalIntegrateInner.call( - this, - stack, - ...this.inputs - ); + return this.internalIntegrateInner.call(this, stack, ...this.inputs); } else if (this.internalIntegrate) { - this.integralFormula = this.internalIntegrate.call(this, stack, ...this.inputs); + return this.internalIntegrate.call(this, stack, ...this.inputs); } else if ( this.inputs.length === 1 && this.internalEvaluate == null && this.hasVariable() ) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - this.integralFormula = this; + return this; } else { throw new Error("Cannot integrate formula without variable"); } } - return this.integralFormula; } /** diff --git a/src/game/formulas/operations.ts b/src/game/formulas/operations.ts index becaec3..c6cbd05 100644 --- a/src/game/formulas/operations.ts +++ b/src/game/formulas/operations.ts @@ -326,24 +326,10 @@ export function invertPow10(value: DecimalSource, lhs: FormulaSource) { throw new Error("Could not invert due to no input being a variable"); } -function internalIntegratePow10(lhs: DecimalSource) { - return Decimal.pow10(lhs).div(Decimal.ln(lhs)); -} - -function internalInvertIntegralPow10(value: DecimalSource, lhs: FormulaSource) { - if (hasVariable(lhs)) { - return lhs.invert(ln10.times(value).ln().div(ln10)); - } - throw new Error("Could not invert due to no input being a variable"); -} - export function integratePow10(stack: SubstitutionStack, lhs: FormulaSource) { if (hasVariable(lhs)) { - return new Formula({ - inputs: [lhs.getIntegralFormula(stack)], - evaluate: internalIntegratePow10, - invert: internalInvertIntegralPow10 - }); + const x = lhs.getIntegralFormula(stack); + return Formula.pow10(x).div(Formula.ln(10)); } throw new Error("Could not integrate due to no input being a variable"); } diff --git a/tests/game/formulas.test.ts b/tests/game/formulas.test.ts index ccaa485..9c77d86 100644 --- a/tests/game/formulas.test.ts +++ b/tests/game/formulas.test.ts @@ -2,10 +2,11 @@ import { createResource, Resource } from "features/resources/resource"; import Formula, { calculateCost, calculateMaxAffordable, + printFormula, unrefFormulaSource } from "game/formulas/formulas"; import type { GenericFormula, InvertibleFormula } from "game/formulas/types"; -import Decimal, { DecimalSource } from "util/bignum"; +import Decimal, { DecimalSource, format } from "util/bignum"; import { beforeAll, describe, expect, test } from "vitest"; import { ref } from "vue"; import "../utils"; @@ -572,7 +573,13 @@ describe("Integrating", () => { // Check if the calculated cost is within 10% of the actual cost, // because this is an approximation expect( - Decimal.sub(actualCost, formula.evaluateIntegral()).abs().div(actualCost).toNumber() + Decimal.sub( + actualCost, + Decimal.add(formula.evaluateIntegral(), formula.calculateConstantOfIntegration()) + ) + .abs() + .div(actualCost) + .toNumber() ).toBeLessThan(0.1); }); @@ -668,7 +675,7 @@ describe("Inverting integrals", () => { test("Inverting integral of nested formulas", () => { const formula = Formula.add(variable, constant).times(constant).pow(2).times(30); - expect(formula.invertIntegral(formula.evaluateIntegral())).compare_tolerance(10, 0.01); + expect(formula.invertIntegral(formula.evaluateIntegral())).compare_tolerance(10); }); test("Inverting integral of nested complex formulas", () => { @@ -929,18 +936,18 @@ describe("Custom Formulas", () => { expect( new Formula({ inputs: [variable], - evaluate: v1 => Decimal.add(v1, 19.5), + evaluate: v1 => Decimal.add(v1, 10), integrate: (stack, v1) => Formula.add(v1, 10) }).evaluateIntegral() - ).compare_tolerance(20)); + ).compare_tolerance(11)); test("Two inputs integrates correctly", () => expect( new Formula({ inputs: [variable, 10], - evaluate: v1 => Decimal.add(v1, 19.5), + evaluate: (v1, v2) => Decimal.add(v1, v2), integrate: (stack, v1, v2) => Formula.add(v1, v2) }).evaluateIntegral() - ).compare_tolerance(20)); + ).compare_tolerance(11)); }); describe("Formula with invertIntegral", () => { @@ -956,7 +963,7 @@ describe("Custom Formulas", () => { expect( new Formula({ inputs: [variable], - evaluate: v1 => Decimal.add(v1, 19.5), + evaluate: v1 => Decimal.add(v1, 10), integrate: (stack, v1) => Formula.add(v1, 10) }).invertIntegral(20) ).compare_tolerance(10)); @@ -964,7 +971,7 @@ describe("Custom Formulas", () => { expect( new Formula({ inputs: [variable, 10], - evaluate: v1 => Decimal.add(v1, 19.5), + evaluate: (v1, v2) => Decimal.add(v1, v2), integrate: (stack, v1, v2) => Formula.add(v1, v2) }).invertIntegral(20) ).compare_tolerance(10)); @@ -1001,14 +1008,25 @@ describe("Buy Max", () => { const maxAffordable = calculateMaxAffordable(Formula.abs(10), resource); expect(() => maxAffordable.value).toThrow(); }); - // https://www.desmos.com/calculator/7ffthe7wi8 - test("Calculates max affordable and cost correctly", () => { - const variable = Formula.variable(0); + test("Calculates 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); - expect(maxAffordable.value).compare_tolerance(7); + let actualAffordable = 0; + let summedCost = Decimal.dZero; + while (true) { + const nextCost = formula.evaluate(actualAffordable); + 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(7) + const actualCost = new Array(actualAffordable) .fill(null) .reduce((acc, _, i) => acc.add(formula.evaluate(i)), new Decimal(0)); const calculatedCost = calculateCost(formula, maxAffordable.value); @@ -1018,5 +1036,33 @@ describe("Buy Max", () => { Decimal.sub(actualCost, calculatedCost).abs().div(actualCost).toNumber() ).toBeLessThan(0.1); }); + test("Calculates max affordable and cost correctly 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); + 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); + // Check if the calculated cost is within 10% of the actual cost, + // because this is an approximation + expect( + Decimal.sub(actualCost, calculatedCost).abs().div(actualCost).toNumber() + ).toBeLessThan(0.1); + }); }); });