Files
bitburner-src/src/Server/BaseServer.ts
T
2025-06-16 16:34:06 -07:00

379 lines
14 KiB
TypeScript

import type { Server as IServer } from "@nsdefs";
import type { CompletedProgramName, LiteratureName, MessageFilename } from "@enums";
import type { IPAddress, ServerName } from "../Types/strings";
import type { FilePath } from "../Paths/FilePath";
import { CodingContract } from "../CodingContract/Contract";
import { RunningScript } from "../Script/RunningScript";
import { Script } from "../Script/Script";
import { TextFile } from "../TextFile";
import { IReturnStatus } from "../types";
import { ScriptFilePath, resolveScriptFilePath, hasScriptExtension } from "../Paths/ScriptFilePath";
import { Directory, resolveDirectory } from "../Paths/Directory";
import { TextFilePath, resolveTextFilePath, hasTextExtension } from "../Paths/TextFilePath";
import { Generic_toJSON, Generic_fromJSON, IReviverValue } from "../utils/JSONReviver";
import { matchScriptPathExact } from "../utils/helpers/scriptKey";
import { createRandomIp } from "../utils/IPAddress";
import { JSONMap } from "../Types/Jsonable";
import { ContentFile, ContentFilePath } from "../Paths/ContentFile";
import { ProgramFilePath, hasProgramExtension } from "../Paths/ProgramFilePath";
import { getKeyList } from "../utils/helpers/getKeyList";
import lodash from "lodash";
import { Settings } from "../Settings/Settings";
import type { ScriptKey } from "../utils/helpers/scriptKey";
import { assertObject } from "../utils/TypeAssertion";
import { clampNumber } from "../utils/helpers/clampNumber";
interface IConstructorParams {
adminRights?: boolean;
hostname: string;
ip?: IPAddress;
isConnectedTo?: boolean;
maxRam?: number;
organizationName?: string;
}
interface writeResult {
overwritten: boolean;
}
/** Abstract Base Class for any Server object */
export abstract class BaseServer implements IServer {
// Coding Contract files on this server
contracts: CodingContract[] = [];
// How many CPU cores this server has.
cpuCores = 1;
// Flag indicating whether the FTP port is open
ftpPortOpen = false;
// Flag indicating whether player has admin/root access to this server
hasAdminRights = false;
// Hostname. Must be unique
hostname: ServerName = "home";
// Flag indicating whether HTTP Port is open
httpPortOpen = false;
// IP Address. Must be unique
ip = "1.1.1.1" as IPAddress;
// Flag indicating whether player is currently connected to this server
isConnectedTo = false;
// RAM (GB) available on this server
maxRam = 0;
// Message files AND Literature files on this Server
messages: (MessageFilename | LiteratureName)[] = [];
// Name of company/faction/etc. that this server belongs to.
// Optional, not applicable to all Servers
organizationName = "";
// Programs on this servers. Contains only the names of the programs
// CompletedProgramNames are all typechecked as valid paths in Program constructor
programs: (ProgramFilePath | CompletedProgramName)[] = [];
// RAM (GB) used. i.e. unavailable RAM
ramUsed = 0;
// RunningScript files on this server. Keyed first by name/args, then by PID.
runningScriptMap = new Map<ScriptKey, Map<number, RunningScript>>();
// RunningScript files loaded from the savegame. Only stored here temporarily,
// this field is undef while the game is running.
savedScripts: RunningScript[] | undefined = undefined;
// Script files on this Server
scripts = new JSONMap<ScriptFilePath, Script>();
// Contains the hostnames of all servers that are immediately
// reachable from this one
serversOnNetwork: string[] = [];
// Flag indicating whether SMTP Port is open
smtpPortOpen = false;
// Flag indicating whether SQL Port is open
sqlPortOpen = false;
// Flag indicating whether the SSH Port is open
sshPortOpen = false;
// Text files on this server
textFiles = new JSONMap<TextFilePath, TextFile>();
// Flag indicating whether this is a purchased server
purchasedByPlayer = false;
// Optional, listed just so they can be accessed on a BaseServer. These will be undefined for HacknetServers.
backdoorInstalled?: boolean;
baseDifficulty?: number;
hackDifficulty?: number;
minDifficulty?: number;
moneyAvailable?: number;
moneyMax?: number;
numOpenPortsRequired?: number;
openPortCount?: number;
requiredHackingSkill?: number;
serverGrowth?: number;
isHacknetServer?: boolean;
constructor(params: IConstructorParams = { hostname: "", ip: createRandomIp() }) {
this.ip = params.ip ? params.ip : createRandomIp();
this.hostname = params.hostname;
this.organizationName = params.organizationName != null ? params.organizationName : "";
this.isConnectedTo = params.isConnectedTo != null ? params.isConnectedTo : false;
//Access information
this.hasAdminRights = params.adminRights != null ? params.adminRights : false;
}
addContract(contract: CodingContract): void {
this.contracts.push(contract);
}
getContract(contractName: string): CodingContract | null {
for (const contract of this.contracts) {
if (contract.fn === contractName) {
return contract;
}
}
return null;
}
/** Get a TextFile or Script depending on the input path type. */
getContentFile(path: ContentFilePath): ContentFile | null {
return (hasTextExtension(path) ? this.textFiles.get(path) : this.scripts.get(path)) ?? null;
}
/** Returns boolean indicating whether the given script is running on this server */
isRunning(path: ScriptFilePath): boolean {
const pattern = matchScriptPathExact(lodash.escapeRegExp(path));
for (const k of this.runningScriptMap.keys()) {
if (pattern.test(k)) {
return true;
}
}
return false;
}
removeContract(contract: CodingContract | string): void {
const index = this.contracts.findIndex((c) => c.fn === (typeof contract === "string" ? contract : contract.fn));
if (index > -1) this.contracts.splice(index, 1);
}
/**
* Remove a file from the server
* @param path Name of file to be deleted
* @returns {IReturnStatus} Return status object indicating whether or not file was deleted
*/
removeFile(path: FilePath): IReturnStatus {
if (hasTextExtension(path)) {
const textFile = this.textFiles.get(path);
if (!textFile) return { res: false, msg: `Text file ${path} not found.` };
this.textFiles.delete(path);
return { res: true };
}
if (hasScriptExtension(path)) {
const script = this.scripts.get(path);
if (!script) return { res: false, msg: `Script ${path} not found.` };
if (this.isRunning(path)) return { res: false, msg: "Cannot delete a script that is currently running!" };
script.invalidateModule();
this.scripts.delete(path);
return { res: true };
}
if (hasProgramExtension(path)) {
const programIndex = this.programs.findIndex((program) => program === path);
if (programIndex === -1) return { res: false, msg: `Program ${path} does not exist` };
this.programs.splice(programIndex, 1);
return { res: true };
}
if (path.endsWith(".lit")) {
const litIndex = this.messages.findIndex((lit) => lit === path);
if (litIndex === -1) return { res: false, msg: `Literature file ${path} does not exist` };
this.messages.splice(litIndex, 1);
return { res: true };
}
if (path.endsWith(".cct")) {
const contractIndex = this.contracts.findIndex((contracts) => contracts.fn === path);
if (contractIndex === -1) return { res: false, msg: `Contract file ${path} does not exist` };
this.contracts.splice(contractIndex, 1);
return { res: true };
}
return { res: false, msg: `Unhandled file extension on file path ${path}` };
}
/**
* Called when a script is run on this server.
* All this function does is add a RunningScript object to the
* `runningScripts` array. It does NOT check whether the script actually can
* be run.
*/
runScript(script: RunningScript): void {
let byPid = this.runningScriptMap.get(script.scriptKey);
if (!byPid) {
byPid = new Map();
this.runningScriptMap.set(script.scriptKey, byPid);
}
byPid.set(script.pid, script);
}
setMaxRam(ram: number): void {
this.maxRam = ram;
}
updateRamUsed(ram: number): void {
this.ramUsed = clampNumber(ram, 0, this.maxRam);
}
pushProgram(program: ProgramFilePath | CompletedProgramName): void {
if (this.programs.includes(program)) return;
// Remove partially created program if there is one
const existingPartialExeIndex = this.programs.findIndex((p) => p.startsWith(program));
// findIndex returns -1 if there is no match, we only want to splice on a match
if (existingPartialExeIndex > -1) this.programs.splice(existingPartialExeIndex, 1);
this.programs.push(program);
}
/**
* Write to a script file
* Overwrites existing files. Creates new files if the script does not exist.
*/
writeToScriptFile(filename: ScriptFilePath, code: string): writeResult {
// Check if the script already exists, and overwrite it if it does
const script = this.scripts.get(filename);
if (script) {
// content setter handles module invalidation
script.content = code;
return { overwritten: true };
}
// Otherwise, create a new script
const newScript = new Script(filename, code, this.hostname);
this.scripts.set(filename, newScript);
return { overwritten: false };
}
// Write to a text file
// Overwrites existing files. Creates new files if the text file does not exist
writeToTextFile(textPath: TextFilePath, txt: string): writeResult {
// Check if the text file already exists, and overwrite if it does
const existingFile = this.textFiles.get(textPath);
// overWrite if already exists
if (existingFile) {
existingFile.content = txt;
return { overwritten: true };
}
// Otherwise create a new text file
const newFile = new TextFile(textPath, txt);
this.textFiles.set(textPath, newFile);
return { overwritten: false };
}
/** Write to a Script or TextFile */
writeToContentFile(path: ContentFilePath, content: string): writeResult {
if (hasTextExtension(path)) return this.writeToTextFile(path, content);
return this.writeToScriptFile(path, content);
}
// Serialize the current object to a JSON save state
// Called by subclasses, not stringify.
toJSONBase(ctorName: string, keys: readonly (keyof this)[]): IReviverValue {
// RunningScripts are stored as a simple array, both for backward compatibility,
// compactness, and ease of filtering them here.
const result = Generic_toJSON(ctorName, this, keys);
assertObject(result.data);
if (Settings.ExcludeRunningScriptsFromSave) {
result.data.runningScripts = [];
return result;
}
const rsArray: RunningScript[] = [];
for (const byPid of this.runningScriptMap.values()) {
for (const rs of byPid.values()) {
if (!rs.temporary) {
rsArray.push(rs);
}
}
}
result.data.runningScripts = rsArray;
return result;
}
// Initializes a Server Object from a JSON save state
// Called by subclasses, not Reviver.
static fromJSONBase<T extends BaseServer>(value: IReviverValue, ctor: new () => T, keys: readonly (keyof T)[]): T {
assertObject(value.data);
const server = Generic_fromJSON(ctor, value.data, keys);
if (value.data.runningScripts != null && Array.isArray(value.data.runningScripts)) {
server.savedScripts = value.data.runningScripts;
}
// If textFiles is not an array, we've already done the 2.3 migration to textFiles and scripts as maps + path changes.
if (!Array.isArray(server.textFiles)) return server;
// Migrate to using maps for scripts and textfiles. This is done here, directly at load, instead of the
// usual upgrade logic, for two reasons:
// 1) Our utility functions depend on it, so the upgrade logic itself needs the data to be in maps, even the logic
// written earlier than 2.3!
// 2) If the upgrade logic throws, and then you soft-reset at the recovery screen (or maybe don't even see the
// recovery screen), you can end up with a "migrated" save that still has arrays.
const newDirectory = resolveDirectory("v2.3FileChanges/") as Directory;
let invalidScriptCount = 0;
// There was a brief dev window where Server.scripts was already a map but the filepath changes weren't in yet.
// Thus, we can't skip this logic just because it's already a map.
const oldScripts = Array.isArray(server.scripts) ? (server.scripts as Script[]) : [...server.scripts.values()];
server.scripts = new JSONMap();
// In case somehow there are previously valid filenames that can't be sanitized, they will go in a new directory with a note.
for (const script of oldScripts) {
// We're about to do type validation on the filename anyway.
if (script.filename.endsWith(".ns")) script.filename = (script.filename + ".js") as ScriptFilePath;
let newFilePath = resolveScriptFilePath(script.filename);
if (!newFilePath) {
newFilePath = `${newDirectory}script${++invalidScriptCount}.js` as ScriptFilePath;
script.content = `// Original path: ${script.filename}. Path was no longer valid\n` + script.content;
}
script.filename = newFilePath;
server.scripts.set(newFilePath, script);
}
let invalidTextCount = 0;
const oldTextFiles = server.textFiles as (TextFile & { fn?: string })[];
server.textFiles = new JSONMap();
for (const textFile of oldTextFiles) {
const oldName = textFile.fn ?? textFile.filename;
delete textFile.fn;
let newFilePath = resolveTextFilePath(oldName);
if (!newFilePath) {
newFilePath = `${newDirectory}text${++invalidTextCount}.txt` as TextFilePath;
textFile.content = `// Original path: ${textFile.filename}. Path was no longer valid\n` + textFile.content;
}
textFile.filename = newFilePath;
server.textFiles.set(newFilePath, textFile);
}
if (invalidScriptCount || invalidTextCount) {
// If we had to migrate names, don't run scripts for this server.
server.savedScripts = [];
}
return server;
}
// Customize a prune list for a subclass.
static getIncludedKeys<T extends BaseServer>(ctor: new () => T): readonly (keyof T)[] {
return getKeyList(ctor, { removedKeys: ["runningScriptMap", "savedScripts", "ramUsed", "isHacknetServer"] });
}
}