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>(); // 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(); // 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(); // 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(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(ctor: new () => T): readonly (keyof T)[] { return getKeyList(ctor, { removedKeys: ["runningScriptMap", "savedScripts", "ramUsed", "isHacknetServer"] }); } }