bitburner-src/test/jest/Netscript/RamCalculation.test.ts
David Walker 1f2e69631e
EDITOR: Use ramOverride() to set compiled script RAM (#1446)
To use this, add a line like "ns.ramOverride(2);" as the first statement
in main(). Not only will it take effect at runtime, but it will now
*also* be parsed at compile time, changing the script's static RAM
limit. Since ns.ramOverride is a 0-cost function, the call to it on
startup then becomes a no-op.

This is an often-requested feature, and allows for scripts to set their
usage without it needing to be explicitly mentioned via args or options
when being launched. This also reduces pressure on the static RAM
analysis to be perfect all the time. (But certain limits, such as
"functions names must be unique across namespaces," remain.)

This also adds a tooltip to the RAM calculation, to make this slightly
discoverable.
2024-07-05 17:32:46 -07:00

187 lines
7.1 KiB
TypeScript

import { Player } from "../../../src/Player";
import { NetscriptFunctions } from "../../../src/NetscriptFunctions";
import { RamCosts, getRamCost, RamCostConstants, RamCostTree } from "../../../src/Netscript/RamCostGenerator";
import { Environment } from "../../../src/Netscript/Environment";
import { RunningScript } from "../../../src/Script/RunningScript";
import { Script } from "../../../src/Script/Script";
import { WorkerScript } from "../../../src/Netscript/WorkerScript";
import { calculateRamUsage } from "../../../src/Script/RamCalculations";
import { ns } from "../../../src/NetscriptFunctions";
import { InternalAPI } from "src/Netscript/APIWrapper";
import { Singularity } from "@nsdefs";
import { ScriptFilePath } from "src/Paths/ScriptFilePath";
type PotentiallyAsyncFunction = (arg?: unknown) => { catch?: PotentiallyAsyncFunction };
/** Get a potentiallyAsyncFunction from a layer of the external ns */
function getFunction(fn: unknown) {
if (typeof fn !== "function") throw new Error("Expected a function at this location.");
return fn as PotentiallyAsyncFunction;
}
function grabCost<API>(ramEntry: RamCostTree<API>[keyof API]) {
if (typeof ramEntry === "function") return ramEntry();
if (typeof ramEntry === "number") return ramEntry;
throw new Error("Invalid ramcost: " + ramEntry);
}
describe("Netscript RAM Calculation/Generation Tests", function () {
jest.spyOn(console, "warn").mockImplementation(() => {});
Player.sourceFiles.set(4, 3);
// For simulating costs of singularity functions.
const baseCost = RamCostConstants.Base;
const maxCost = RamCostConstants.Max;
const script = new Script();
/** Creates a RunningScript object which calculates static ram usage */
function createRunningScript(code: string) {
script.code = code;
// Force ram calculation reset
script.ramUsage = null;
const ramUsage = script.getRamUsage(new Map());
if (!ramUsage) throw new Error("Ram usage should be defined.");
const runningScript = new RunningScript(script, ramUsage);
return runningScript;
}
/** Runs a Netscript function and properly catches an error even if it returns promise. */
function tryFunction(fn: PotentiallyAsyncFunction) {
try {
fn()?.catch?.(() => undefined);
} catch {
// Intentionally empty
}
}
let scriptRef = createRunningScript("");
//Since it is expensive to create a workerscript and wrap the ns API, this is done once
const workerScript = {
args: [] as string[],
code: "",
delay: null,
dynamicLoadedFns: {},
dynamicRamUsage: RamCostConstants.Base,
env: new Environment(),
ramUsage: scriptRef.ramUsage,
scriptRef,
};
const nsExternal = NetscriptFunctions(workerScript as unknown as WorkerScript);
function combinedRamCheck(
fn: PotentiallyAsyncFunction,
fnPath: string[],
expectedRamCost: number,
extraLayerCost = 0,
) {
const code = `${fnPath.join(".")}();\n`.repeat(3);
const filename = "testfile.js" as ScriptFilePath;
const fnName = fnPath[fnPath.length - 1];
const server = "testserver";
//check imported getRamCost fn vs. expected ram from test
expect(getRamCost(fnPath, true)).toEqual(expectedRamCost);
// Static ram check
const staticCost = calculateRamUsage(code, filename, new Map(), server).cost;
expect(staticCost).toBeCloseTo(Math.min(baseCost + expectedRamCost + extraLayerCost, maxCost));
// reset workerScript for dynamic check
scriptRef = createRunningScript(code);
Object.assign(workerScript, {
code,
scriptRef,
ramUsage: scriptRef.ramUsage,
dynamicRamUsage: baseCost,
env: new Environment(),
dynamicLoadedFns: {},
});
workerScript.env.vars = nsExternal;
// Run the function through the workerscript's args
if (typeof fn === "function") {
tryFunction(fn);
tryFunction(fn);
tryFunction(fn);
} else {
throw new Error(`Invalid function specified: [${fnPath.toString()}]`);
}
expect(workerScript.dynamicLoadedFns).toHaveProperty(fnName);
expect(workerScript.dynamicRamUsage).toBeCloseTo(Math.min(expectedRamCost + baseCost, maxCost), 5);
expect(workerScript.dynamicRamUsage).toBeCloseTo(scriptRef.ramUsage - extraLayerCost, 5);
}
describe("ns", () => {
function testLayer<API>(
internalLayer: InternalAPI<API>,
externalLayer: API,
ramLayer: RamCostTree<API>,
path: string[],
extraLayerCost: number,
) {
describe(path[path.length - 1] ?? "Base ns layer", () => {
for (const [key, val] of Object.entries(internalLayer) as [keyof API, InternalAPI<API>[keyof API]][]) {
const newPath = [...path, key as string];
if (typeof val === "function") {
const fn = getFunction(externalLayer[key]);
const fnName = newPath.join(".");
if (!(key in ramLayer)) {
throw new Error("Missing ramcost for " + fnName);
}
const expectedRam = grabCost(ramLayer[key]);
it(`${fnName}()`, () => combinedRamCheck(fn, newPath, expectedRam, extraLayerCost));
}
//A layer should be the only other option. Hacknet is currently the only layer with a layer cost.
else if (typeof val === "object" && key !== "enums") {
//hacknet is currently the only layer with a layer cost.
const layerCost = key === "hacknet" ? 4 : 0;
testLayer(val as InternalAPI<unknown>, externalLayer[key], ramLayer[key], newPath, layerCost);
}
// Other things like args, enums, etc. have no cost
}
});
}
testLayer(ns, nsExternal, RamCosts, [], 0);
});
describe("Singularity multiplier checks", () => {
// Checks were already done above for SF4.3 having normal ramcost.
Player.sourceFiles.set(4, 3);
const lvlToMult = { 0: 16, 1: 16, 2: 4 };
const externalSingularity = nsExternal.singularity;
const ramCostSingularity = RamCosts.singularity;
const singObjects = (
Object.entries(ns.singularity) as [keyof Singularity, InternalAPI<Singularity>[keyof Singularity]][]
)
.filter(([__, v]) => typeof v === "function")
.map(([name]) => {
return {
name,
baseRam: grabCost<Singularity>(ramCostSingularity[name]),
};
});
for (const lvl of [0, 1, 2] as const) {
it(`SF4.${lvl} check for x${lvlToMult[lvl]} costs`, () => {
Player.sourceFiles.set(4, lvl);
const expectedMult = lvlToMult[lvl];
singObjects.forEach(({ name, baseRam }) => {
const fn = getFunction(externalSingularity[name]);
combinedRamCheck(fn, ["singularity", name], baseRam * expectedMult);
});
});
}
});
describe("ramOverride checks", () => {
test.each([
["ns.ramOverride(5)", 5],
["ramOverride(5)", 5],
["ns.ramOverride(5 * 1024)", baseCost], // Constant expressions are not handled yet
])("%s", (code, expected) => {
const fullCode = `export function main(ns) { ${code} }`;
const result = calculateRamUsage(fullCode, "testfile.js", new Map(), "testserver");
expect(result.errorMessage).toBe(undefined);
expect(result.cost).toBe(expected);
});
});
});