Merge pull request #1129 from bbassie/refactor/async-factory

Replace bootstrap methods with async factories
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-10-27 21:31:49 +00:00
commit e36a76acc5
18 files changed
+540 -281

No files matched your search

+7 -8
View File
@@ -147,15 +147,15 @@ async function handleBootstrapCommand(
path.join(databaseDirectory, "roles.json"),
);
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const roles = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
).bootstrap());
));
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const users = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
).bootstrap());
));
const userManager = new UserManager(users, roles, controllerConfig);
@@ -474,13 +474,12 @@ async function startup() {
return;
}
controller = new Controller(
clusterLogger,
pluginInfos,
controller = await Controller.fromConfig(
controllerConfig,
pluginInfos,
clusterLogger,
Boolean(args.canRestart),
Boolean(args.recovery),
...await Controller.bootstrap(controllerConfig)
);
// Refuse to start if there are no users loaded
+31 -19
View File
@@ -148,7 +148,14 @@ export default class Controller {
}
});
static async bootstrap(config: lib.ControllerConfig) {
/** Create a controller with its datastores and mod store loaded from disk */
static async fromConfig(
config: lib.ControllerConfig,
pluginInfos: lib.PluginNodeEnvInfo[],
clusterLogger: winston.Logger,
canRestart = false,
recoveryMode = false,
) {
let databaseDirectory = config.get("controller.database_directory");
await fs.mkdir(databaseDirectory, { recursive: true });
@@ -158,23 +165,23 @@ export default class Controller {
path.join(databaseDirectory, "roles.json"),
);
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const systems = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "systems.json"),
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
this.migrateSystems,
).bootstrap());
));
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const hosts = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "hosts.json"),
HostRecord.fromJSON.bind(HostRecord),
this.migrateHosts, this.finaliseHosts,
).bootstrap());
));
const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const instances = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "instances.json"),
InstanceRecord.fromJSON.bind(InstanceRecord),
this.migrateInstances, this.finaliseInstances,
).bootstrap());
));
// Save plugin versions written while loading
for (const instance of instances.values()) {
if (instance.config.dirty) {
@@ -182,33 +189,33 @@ export default class Controller {
}
}
const saves = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const saves = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "saves.json"),
lib.SaveDetails.fromJSON.bind(lib.SaveDetails)
).bootstrap());
));
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const modPacks = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "mod-packs.json"),
lib.ModPack.fromJSON.bind(lib.ModPack),
this.migrateModPacks,
).bootstrap());
));
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const roles = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
this.migrateRoles,
).bootstrap());
));
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const users = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
undefined, this.finaliseUsers,
).bootstrap());
));
const notes = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
const notes = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "notes.json"),
lib.Note.fromJSON.bind(lib.Note),
).bootstrap());
));
let modsDirectory = config.get("controller.mods_directory");
await fs.mkdir(modsDirectory, { recursive: true });
@@ -223,7 +230,12 @@ export default class Controller {
lib.Role.ensureDefaultAdminRole(roles);
lib.Role.ensureDefaultPlayerRole(roles);
return [
return new this(
clusterLogger,
pluginInfos,
config,
canRestart,
recoveryMode,
systems,
hosts,
instances,
@@ -233,7 +245,7 @@ export default class Controller {
roles,
users,
notes,
] as const;
);
}
constructor(
+2 -3
View File
@@ -233,13 +233,12 @@ async function startHost() {
}
let hostConnector = new HostConnector(hostConfig, pluginInfos);
host = new Host(
hostConnector,
host = await Host.fromConfig(
hostConfig,
pluginInfos,
hostConnector,
Boolean(args.canRestart),
Boolean(args.recovery),
...await Host.bootstrap(hostConfig)
);
// Handle interrupts
+31 -13
View File
@@ -353,6 +353,8 @@ export default class Host extends lib.Link {
_startup = true;
_disconnecting = false;
_shuttingDown = false;
/** Instances waiting on {@link Instance.fromConfig} */
_startingInstances = new Set<number>();
startedAtMs = Date.now();
@@ -360,12 +362,18 @@ export default class Host extends lib.Link {
"_warning": "Changes to this file will be overwritten by the controller's copy.",
};
static async bootstrap(hostConfig: lib.HostConfig) {
/** Create a host with its mod store loaded from disk */
static async fromConfig(
hostConfig: lib.HostConfig,
pluginInfos: lib.PluginNodeEnvInfo[],
connector: HostConnector,
canRestart = false,
recoveryMode = false,
) {
const modsDirectory = hostConfig.get("host.mods_directory");
await fs.mkdir(modsDirectory, { recursive: true });
return [
await lib.ModStore.fromDirectory(modsDirectory),
] as const;
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
return new this(connector, hostConfig, pluginInfos, canRestart, recoveryMode, modStore);
}
constructor(
@@ -788,7 +796,7 @@ export default class Host extends lib.Link {
this.send(
new lib.InstanceStatusChangedEvent(
instanceId,
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.status : this._unconnectedStatus(instanceId),
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.version
@@ -904,6 +912,11 @@ export default class Host extends lib.Link {
}
}
/** Status of an instance without a connection */
_unconnectedStatus(instanceId: number) {
return this._startingInstances.has(instanceId) ? "starting" as const : "stopped" as const;
}
/**
* Initialize and connect an unloaded instance
*
@@ -912,22 +925,27 @@ export default class Host extends lib.Link {
*/
async _connectInstance(instanceId: number) {
let unloadedInstance = this.getRequestInstance(instanceId);
if (this.instanceConnections.has(instanceId)) {
if (this.instanceConnections.has(instanceId) || this._startingInstances.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
}
let hostAddress = new lib.Address(lib.Address.host, this.config.get("host.id"));
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
let instance = new Instance(
this, connectionClient,
unloadedInstance.path, this.config.get("host.factorio_directory"),
unloadedInstance.config
);
let instance: Instance;
this._startingInstances.add(instanceId);
try {
instance = await Instance.fromConfig(
unloadedInstance.config, this, connectionClient,
unloadedInstance.path, this.config.get("host.factorio_directory"),
);
} finally {
this._startingInstances.delete(instanceId);
}
let instanceConnection = new InstanceConnection(connectionServer, this, instance);
this.instanceConnections.set(instanceId, instanceConnection);
await instance.init(this.pluginInfos);
await instance.loadPlugins(this.pluginInfos);
return instanceConnection;
}
@@ -1295,7 +1313,7 @@ export default class Host extends lib.Link {
let instanceConnection = this.instanceConnections.get(instanceId);
list.push(new lib.HostInstanceUpdate(
instance.config.toRemote("controller"),
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.status : this._unconnectedStatus(instanceId),
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection ? instanceConnection.instance.server.version : undefined,
));
+80 -51
View File
@@ -10,7 +10,7 @@ const execAsync = util.promisify(exec);
// internal libraries
import * as lib from "@clusterio/lib";
import { FactorioServer } from "./server.js";
import { FactorioServer, checkForUpdates } from "./server.js";
import { loadModules, patch } from "./patch.js";
import { exportData } from "./export.js";
import type Host from "./Host.js";
@@ -32,6 +32,13 @@ const scriptCommands = [
"/silent-command", "/sc",
];
function instanceLogger(config: lib.InstanceConfig) {
return lib.logger.child({
instance_id: config.get("instance.id"),
instance_name: config.get("instance.name"),
});
}
// Statuses the instance passes through on its way to running or stopped.
const transitoryStatuses: ReadonlySet<Instance["_status"]> = new Set([
"starting", "stopping", "creating_save", "exporting_data",
@@ -191,8 +198,8 @@ export default class Instance extends lib.Link {
host: Host,
connector: lib.VirtualConnector,
dir: string,
factorioDir: string,
instanceConfig: lib.InstanceConfig
instanceConfig: lib.InstanceConfig,
server: FactorioServer,
) {
super(connector);
this._host = host;
@@ -202,10 +209,7 @@ export default class Instance extends lib.Link {
this.id = this.config.get("instance.id");
this.logger = lib.logger.child({
instance_id: this.id,
instance_name: this.name,
});
this.logger = instanceLogger(instanceConfig);
this.hooks = new InstanceHooks(this.logger);
@@ -225,28 +229,7 @@ export default class Instance extends lib.Link {
};
this.config.on("fieldChanged", this._configFieldChanged);
let serverOptions = {
logger: this.logger,
// TODO: factorio.version is not validated
version: this.config.get("factorio.version") as lib.TargetVersion,
executablePath: this.config.get("factorio.executable_path") ?? undefined,
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
rconPassword: this.config.get("factorio.rcon_password") ?? undefined,
enableLuaUdp: this.config.get("factorio.enable_lua_udp"),
luaUdpPort: this.config.get("factorio.lua_udp_port") ?? undefined,
enableWhitelist: this.config.get("factorio.enable_whitelist"),
enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"),
verboseLogging: this.config.get("factorio.verbose_logging"),
consoleLogging: this.config.get("factorio.console_logging"),
stripPaths: this.config.get("factorio.strip_paths"),
maxConcurrentCommands: this.config.get("factorio.max_concurrent_commands"),
shutdownTimeoutMs: this.config.get("factorio.shutdown_timeout") * 1000,
};
this.server = new FactorioServer(
factorioDir, this._dir, serverOptions
);
this.server = server;
this.server.on("output", (parsed, line) => {
this.logger.log("server", { message: line, instance_id: this.id, parsed });
@@ -670,20 +653,22 @@ end`.replace(/\r?\n/g, " ");
this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`));
}
async _loadStats() {
static async _readStats(statsPath: string) {
let instanceStats;
try {
instanceStats = JSON.parse(await fs.readFile(this.path("instance-stats.json"), "utf8"));
instanceStats = JSON.parse(await fs.readFile(statsPath, "utf8"));
} catch (err: any) {
if (err.code === "ENOENT") {
return;
return { playerStats: new Map<string, lib.PlayerStats>(), playerAutosaveSlot: 1 };
}
throw err;
}
this.playerStats = new Map(instanceStats["players"].map(
([id, stats]: [number, Static<typeof lib.PlayerStats.jsonSchema>]) => [id, new lib.PlayerStats(stats)])
);
this._playerAutosaveSlot = instanceStats["player_autosave_slot"] || 1;
return {
playerStats: new Map<string, lib.PlayerStats>(instanceStats["players"].map(
([id, stats]: [string, Static<typeof lib.PlayerStats.jsonSchema>]) => [id, new lib.PlayerStats(stats)]
)),
playerAutosaveSlot: (instanceStats["player_autosave_slot"] as number) || 1,
};
}
async _saveStats() {
@@ -694,21 +679,35 @@ end`.replace(/\r?\n/g, " ");
await lib.safeOutputFile(this.path("instance-stats.json"), content);
}
async init(pluginInfos: lib.PluginNodeEnvInfo[]) {
// After changing the status this function is responsible for calling notifyExit if it throws.
this.notifyStatus("starting");
/**
* Create an instance with a Factorio server, downloading Factorio if needed
*
* The host reports the starting status for the instance while this runs.
*/
static async fromConfig(
config: lib.InstanceConfig,
host: Host,
connector: lib.VirtualConnector,
dir: string,
factorioDir: string,
) {
const id = config.get("instance.id");
// TODO: factorio.version is not validated
const targetVersion = config.get("factorio.version") as lib.TargetVersion;
const gamePort = config.get("factorio.game_port") ?? await host.assignFreeGamePort(id);
host.send(new lib.InstanceStatusChangedEvent(id, "starting", gamePort, targetVersion));
let server: FactorioServer;
let stats: Awaited<ReturnType<typeof Instance._readStats>>;
try {
if (this.config.get("factorio.game_port") === null) {
this.server.gamePort = await this._host.assignFreeGamePort(this.id);
}
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
await this._loadStats();
const factorioVersions = await host.sendTo("controller", new lib.FactorioVersionsRequest());
stats = await Instance._readStats(path.join(dir, "instance-stats.json"));
// Resolve a release channel target (e.g. "stable") to a concrete
// version using the controller's cached latest-releases data, so the
// existing download/lookup logic can fetch and run it.
const targetVersion = this.config.get("factorio.version") as string;
let version = targetVersion;
if (lib.isReleaseChannel(targetVersion)) {
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
const releases = await host.sendTo("controller", new lib.LatestReleasesRequest());
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
if (!resolved) {
throw new Error(
@@ -716,16 +715,46 @@ end`.replace(/\r?\n/g, " ");
"latest-releases unavailable and no cached copy on the controller."
);
}
this.server.setTargetVersion(resolved);
version = resolved;
}
await this.server.checkForUpdates(factorioVersions);
await this.server.init();
const logger = instanceLogger(config);
await checkForUpdates(factorioDir, version, factorioVersions, logger);
server = await FactorioServer.fromOptions(factorioDir, dir, {
logger,
version,
executablePath: config.get("factorio.executable_path") ?? undefined,
gamePort,
rconPort: config.get("factorio.rcon_port") ?? undefined,
rconPassword: config.get("factorio.rcon_password") ?? undefined,
enableLuaUdp: config.get("factorio.enable_lua_udp"),
luaUdpPort: config.get("factorio.lua_udp_port") ?? undefined,
enableWhitelist: config.get("factorio.enable_whitelist"),
enableAuthserverBans: config.get("factorio.enable_authserver_bans"),
verboseLogging: config.get("factorio.verbose_logging"),
consoleLogging: config.get("factorio.console_logging"),
stripPaths: config.get("factorio.strip_paths"),
maxConcurrentCommands: config.get("factorio.max_concurrent_commands"),
shutdownTimeoutMs: config.get("factorio.shutdown_timeout") * 1000,
});
} catch (err) {
this.notifyExit();
await this.sendSaveListUpdate();
host.send(new lib.InstanceStatusChangedEvent(id, "stopped", gamePort, targetVersion));
await host.sendSaveListUpdate(id, path.join(dir, "saves"));
throw err;
}
const instance = new this(host, connector, dir, config, server);
instance._status = "starting";
instance.playerStats = stats.playerStats;
instance._playerAutosaveSlot = stats.playerAutosaveSlot;
return instance;
}
/**
* Load the instance plugins and report the instance as initialised
*
* Must be called once the instance is connected to the host.
*/
async loadPlugins(pluginInfos: lib.PluginNodeEnvInfo[]) {
// load plugins
const plugins: Record<string, string> = {};
for (let pluginInfo of pluginInfos) {
+98 -94
View File
@@ -185,6 +185,73 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
// Factorio downloads in progress by target directory, shared by every server in the process
const downloadsInProgress = new Map<string, Promise<void>>();
/**
* Download the newest Factorio version matching the target if not installed
*
* Call before {@link FactorioServer.fromOptions} for it to find the new version.
*
* @param factorioDir - Path to Factorio installation dirs.
* @param targetVersion - Version to look for, or "latest".
* @param availableVersions - Versions available for download.
* @param logger - Logger to report the download on.
*/
export async function checkForUpdates(
factorioDir: string,
targetVersion: lib.TargetVersion,
availableVersions: lib.ExternalFactorioVersion[],
logger: lib.Logger = lib.logger,
) {
const latestVersion = availableVersions
.filter(v => (
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
))
.sort((v1, v2) => versionOrder(v1.version, v2.version))
[0];
if (!latestVersion) {
return;
}
// A version that is still being extracted can already look installed
const dst = path.join(factorioDir, latestVersion.version);
const installedVersions = await listFactorioVersions(factorioDir);
if (!installedVersions.versions.has(latestVersion.version) || downloadsInProgress.has(dst)) {
const v = latestVersion.version;
// Can't download if not linux or this is a direct install
if (installedVersions.direct) {
logger.info(
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
`because ${factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
`layout, where ${factorioDir} contains a subdirectory per version, to enable automatic ` +
"downloads."
);
return;
}
if (process.platform !== "linux") {
logger.info(
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
"(automatic downloads are only supported on Linux)."
);
return;
}
// Download the newer version, or wait for another server already downloading it
let download = downloadsInProgress.get(dst);
if (download) {
logger.info(`A newer version of factorio is available (${v}), waiting for download...`);
} else {
logger.info(`A newer version of factorio is available (${v}), starting download...`);
download = downloadAndExtractTar(latestVersion.headlessUrl, dst).finally(() => {
downloadsInProgress.delete(dst);
});
downloadsInProgress.set(dst, download);
}
await download;
logger.info(`Downloaded factorio version ${v}`);
}
}
/**
* Find factorio data directory of the given version
*
@@ -670,9 +737,8 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_factorioDir: string;
_writeDir: string;
// Resolved in init
_version: lib.FullVersion | null = null;
_dataDir: string | null = null;
_version: lib.FullVersion;
_dataDir: string;
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
// https://forums.factorio.com/viewtopic.php?t=123673
@@ -680,8 +746,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_whitelist = new Set<string>();
_logger: lib.Logger;
_targetVersion: lib.TargetVersion;
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
_state: "init" | "create" | "running" | "stopping" = "init";
_server: child_process.ChildProcessWithoutNullStreams | null = null;
_rconClient: Rcon | null= null;
_rconReady = false;
@@ -697,27 +762,36 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
/**
* Create a Factorio server interface
* Create a Factorio server interface for the version found by {@link fromOptions}
*
* @param factorioDir - Directory of the Factorio install(s).
* @param writeDir - Directory to write runtime data to.
* @param options - Optional parameters.
* @param dataDir - Data directory of the Factorio version to run.
* @param version - Factorio version to run.
* @param options - Optional parameters, `version` is ignored.
*/
constructor(factorioDir: string, writeDir: string, options: FactorioServerOptions) {
constructor(
factorioDir: string,
writeDir: string,
dataDir: string,
version: lib.FullVersion,
options: FactorioServerOptions & { rconPassword: string },
) {
super();
this._factorioDir = factorioDir;
this._writeDir = writeDir;
this._dataDir = dataDir;
this._version = version;
this._logger = options.logger || lib.logger;
this._targetVersion = options.version || "latest";
this.executablePath = options.executablePath;
/** UDP port used for hosting the Factorio game server on */
this.gamePort = options.gamePort || randomDynamicPort();
/** TCP port used for RCON on the Factorio game server */
this.rconPort = options.rconPort || randomDynamicPort();
/** Password used for RCON on the Factorio game server */
this.rconPassword = options.rconPassword as string; // init will generate one if not there
this.rconPassword = options.rconPassword;
/** Enable UDP messaging with the Factorio game server */
this.enableLuaUdp = options.enableLuaUdp || false;
/** UDP port the Factorio game server receives Lua UDP messages on */
@@ -886,7 +960,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
// Factorio before 2.1.10 crashes when a packet is received on a headless server
_checkLuaUdpVersion() {
if (this.enableLuaUdp && lib.integerFullVersion(this._version!) < lib.integerFullVersion("2.1.10")) {
if (this.enableLuaUdp && lib.integerFullVersion(this._version) < lib.integerFullVersion("2.1.10")) {
throw new Error("Lua UDP requires Factorio 2.1.10 or later");
}
}
@@ -1075,94 +1149,25 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}
/**
* Override the target version of Factorio to run
* Create a Factorio server interface for an installed version
*
* Used to substitute a concrete version for a release channel target
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
* {@link init} run. Must be called before the server is initialized.
*/
setTargetVersion(version: lib.TargetVersion) {
this._check(["new"]);
this._targetVersion = version;
}
/**
* Initialize class instance
*
* Must be called before instances of this class can be used.
* @param factorioDir - Directory of the Factorio install(s).
* @param writeDir - Directory to write runtime data to.
* @param options - Optional parameters.
* @throws {Error} if the requested version of Factorio was not found.
*/
async init() {
this._check(["new"]);
[this._dataDir, this._version] = await findVersion(this._factorioDir, this._targetVersion);
this.rconPassword = this.rconPassword as string | undefined ?? await generatePassword(10);
this._state = "init";
}
/**
* Check of updated versions to download
*
* Should be called before init for the new version to be detected during init
*/
async checkForUpdates(availableVersions: lib.ExternalFactorioVersion[]) {
const targetVersion = this._targetVersion;
const latestVersion = availableVersions
.filter(v => (
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
))
.sort((v1, v2) => versionOrder(v1.version, v2.version))
[0];
if (!latestVersion) {
return;
}
// A version that is still being extracted can already look installed
const dst = path.join(this._factorioDir, latestVersion.version);
const installedVersions = await listFactorioVersions(this._factorioDir);
if (!installedVersions.versions.has(latestVersion.version) || downloadsInProgress.has(dst)) {
const v = latestVersion.version;
// Can't download if not linux or this is a direct install
if (installedVersions.direct) {
this._logger.info(
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
`because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
`layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` +
"downloads."
);
return;
}
if (process.platform !== "linux") {
this._logger.info(
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
"(automatic downloads are only supported on Linux)."
);
return;
}
// Download the newer version, or wait for another server already downloading it
let download = downloadsInProgress.get(dst);
if (download) {
this._logger.info(`A newer version of factorio is available (${v}), waiting for download...`);
} else {
this._logger.info(`A newer version of factorio is available (${v}), starting download...`);
download = downloadAndExtractTar(latestVersion.headlessUrl, dst).finally(() => {
downloadsInProgress.delete(dst);
});
downloadsInProgress.set(dst, download);
}
await download;
this._logger.info(`Downloaded factorio version ${v}`);
}
static async fromOptions(factorioDir: string, writeDir: string, options: FactorioServerOptions) {
const [dataDir, version] = await findVersion(factorioDir, options.version || "latest");
const rconPassword = options.rconPassword ?? await generatePassword(10);
return new this(factorioDir, writeDir, dataDir, version, { ...options, rconPassword });
}
/**
* The version of Factorio in use. This will be the actual version if
* "latest" (the default) was specified as `factorioVersion` to the constructor.
* This will be undefined before the server is initialized, or if init failed.
* "latest" (the default) was passed to {@link fromOptions}.
*/
get version(): lib.FullVersion | undefined {
return this._version ?? undefined;
get version(): lib.FullVersion {
return this._version;
}
/**
@@ -1681,14 +1686,13 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
* Return path in data directory
*
* Creates a path using path.join with the given parts that's relative to
* the data directory of the Factorio server. Not valid before init has
* been called.
* the data directory of the Factorio server.
*
* @param parts - Extra parts to add to the data path.
* @returns Data directory path.
*/
dataPath(...parts: string[]) {
return path.join(this._dataDir!, ...parts);
return path.join(this._dataDir, ...parts);
}
/**
+2 -2
View File
@@ -435,7 +435,7 @@ export class DebugWsMessageEvent {
export class FactorioVersionsRequest {
declare ["constructor"]: typeof FactorioVersionsRequest;
static type = "request" as const;
static src = ["control", "instance"] as const;
static src = ["control", "host", "instance"] as const;
static dst = "controller" as const;
static permission = "core.external.get_factorio_versions" as const;
@@ -457,7 +457,7 @@ export class FactorioVersionsRequest {
export class LatestReleasesRequest {
declare ["constructor"]: typeof LatestReleasesRequest;
static type = "request" as const;
static src = ["control", "instance"] as const;
static src = ["control", "host", "instance"] as const;
static dst = "controller" as const;
static permission = "core.external.get_latest_releases" as const;
+10 -5
View File
@@ -17,11 +17,6 @@ export abstract class DatastoreProvider<
> {
abstract save(data: Map<K, V>): Promise<void>
abstract load(): Promise<Map<K, V>>
// Load data from the source and return it, this should be passed to the constructor of a datastore
async bootstrap() {
return [this, await this.load()] as const;
}
}
// Store all data in memory, used as a default and should be avoided
@@ -251,6 +246,11 @@ export class KeyValueDatastore<
K extends DatastoreKey,
V extends DatastoreValue,
> extends Datastore<K, V, [key: K, value: V, isDeleted?: boolean]> {
// Create a datastore populated with the data loaded from the provider
static async fromProvider<K extends DatastoreKey, V extends DatastoreValue>(provider: DatastoreProvider<K, V>) {
return new this(provider, await provider.load());
}
// Set the value in the datastore, be careful of race conditions if you await any functions before calling set
set(key: K, value: V) {
this.data.set(key, value);
@@ -316,6 +316,11 @@ export class SubscribableDatastore<
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
}
// Create a datastore populated with the data loaded from the provider
static async fromProvider<V extends SubscribableValue>(provider: DatastoreProvider<V["id"], V>) {
return new this(provider, await provider.load());
}
async load() {
await super.load();
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
+72
View File
@@ -14,6 +14,7 @@ import {
addPluginConfigFields, renamePermissionPrefix,
} from "@clusterio/lib";
import * as lib from "@clusterio/lib";
import "../setup_logging.js";
class MockEvent {}
@@ -57,6 +58,77 @@ describe("controller/src/Controller", function() {
assert(list.check(ip), `${ip} missing from list`);
}
}
describe(".fromConfig()", function() {
let dir;
beforeEach(async function() {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-controller-"));
});
afterEach(async function() {
await fs.rm(dir, { recursive: true, force: true });
});
it("should load datastores and mods from disk", async function() {
const databaseDirectory = path.join(dir, "database");
const modsDirectory = path.join(dir, "mods");
await fs.mkdir(databaseDirectory);
await fs.writeFile(path.join(databaseDirectory, "notes.json"), JSON.stringify([
{ resourceType: "instance", resourceId: "100", content: "hello" },
]));
const config = new ControllerConfig("controller", {
"controller.version": controllerVersion,
"controller.database_directory": databaseDirectory,
"controller.mods_directory": modsDirectory,
});
const loaded = await Controller.fromConfig(config, [], {}, true, false);
assert(loaded instanceof Controller);
assert.equal(loaded.canRestart, true);
assert.equal(loaded.recoveryMode, false);
assert.equal(loaded.notes.get("instance/100").content, "hello");
assert.equal(loaded.modStore.modsDirectory, modsDirectory);
assert(loaded.roles.size > 0, "default roles were not added");
assert(loaded.modPacks.size > 0, "default mod packs were not added");
await fs.access(modsDirectory);
});
describe("with a plugin version change", function() {
before(function() {
addPluginConfigFields([{ name: "from_config_plugin", version: "1.2.0", instanceEntrypoint: "x" }]);
});
afterEach(function() {
lib.Config.pluginVersions.clear();
});
after(function() {
for (const Config of [lib.ControllerConfig, lib.HostConfig, lib.InstanceConfig]) {
for (const name of Object.keys(Config.fieldDefinitions)) {
if (name.startsWith("from_config_plugin.")) {
delete Config.fieldDefinitions[name];
}
}
}
});
it("should save instances whose config changed while loading", async function() {
const databaseDirectory = path.join(dir, "database");
const instancesPath = path.join(databaseDirectory, "instances.json");
await fs.mkdir(databaseDirectory);
const instanceConfig = new InstanceConfig("controller", {
"instance.id": 7, "from_config_plugin.version": "1.0.0",
});
await fs.writeFile(instancesPath, JSON.stringify([
{ config: instanceConfig.toJSON(), status: "unassigned" },
]));
lib.Config.pluginVersions.set("from_config_plugin", "1.2.0");
const config = new ControllerConfig("controller", {
"controller.version": controllerVersion,
"controller.database_directory": databaseDirectory,
"controller.mods_directory": path.join(dir, "mods"),
});
const loaded = await Controller.fromConfig(config, [], {});
await loaded.instances.records.save();
const [saved] = JSON.parse(await fs.readFile(instancesPath, "utf8"));
assert.equal(saved.config["from_config_plugin.version"], "1.2.0");
});
});
});
describe(".loadJsonObject()", function() {
let dir;
before(async function() {
+38
View File
@@ -62,6 +62,44 @@ describe("host/src/Host", function() {
});
});
describe("class Host", function() {
describe(".fromConfig()", function() {
const modsDirectory = path.join("temp", "test", "host_from_config", "mods");
beforeEach(async function() {
await fs.rm(path.dirname(modsDirectory), { recursive: true, force: true });
});
it("should create the mods directory and load the mod store", async function() {
const hostConfig = new lib.HostConfig("host", { "host.mods_directory": modsDirectory });
const host = await Host.fromConfig(hostConfig, [], new HostConnector(hostConfig, []), true, false);
assert(host instanceof Host);
assert.equal(host.canRestart, true);
assert.equal(host.recoveryMode, false);
assert.equal(host.modStore.modsDirectory, modsDirectory);
await fs.access(modsDirectory);
});
});
describe("._connectInstance()", function() {
const originalFromConfig = Instance.fromConfig;
afterEach(function() {
Instance.fromConfig = originalFromConfig;
});
it("should reject a second start while the instance is starting", async function() {
const hostConfig = new lib.HostConfig("host");
const host = new Host(new HostConnector(hostConfig, []), hostConfig, []);
const instanceConfig = new lib.InstanceConfig("host", { "instance.id": 7 });
host.assignedInstances.set(7, { path: "dir", config: instanceConfig });
let fail;
Instance.fromConfig = () => new Promise((resolve, reject) => { fail = reject; });
const first = host._connectInstance(7);
await assert.rejects(host._connectInstance(7), /Instance with ID 7 is running/);
assert.equal(host._unconnectedStatus(7), "starting");
fail(new Error("start failed"));
await assert.rejects(first, /start failed/);
assert.equal(host._unconnectedStatus(7), "stopped");
assert.equal(host.instanceConnections.has(7), false);
});
});
describe(".handleSyncUserListsEvent()", function() {
const hostAddress = lib.Address.fromShorthand({ hostId: 1 });
const instanceAddress = lib.Address.fromShorthand({ instanceId: 1 });
+75 -2
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import * as lib from "@clusterio/lib";
@@ -12,6 +13,79 @@ const addr = lib.Address.fromShorthand;
describe("class Instance", function() {
describe(".fromConfig()", function() {
const factorioDir = path.join("test", "file", "factorio");
const dir = path.join("temp", "test", "instance_from_config");
let host;
let sent;
let saveListUpdates;
let requests;
function makeConfig(fields) {
return new lib.InstanceConfig("host", { "instance.id": 5, "instance.name": "foo", ...fields });
}
function fromConfig(config) {
const connector = new MockConnector(addr({ instanceId: 5 }), addr({ hostId: 1 }));
return Instance.fromConfig(config, host, connector, dir, factorioDir);
}
beforeEach(async function() {
await fs.rm(dir, { recursive: true, force: true });
await fs.mkdir(dir, { recursive: true });
sent = [];
saveListUpdates = [];
requests = [];
host = {
assignFreeGamePort: async () => 34100,
send(event) { sent.push(event); },
async sendTo(dst, request) {
requests.push(request.constructor.name);
if (request instanceof lib.FactorioVersionsRequest) {
return [];
}
if (request instanceof lib.LatestReleasesRequest) {
return { stable: { headless: "0.1.1" } };
}
throw new Error(`Unexpected request ${request.constructor.name}`);
},
async sendSaveListUpdate(instanceId, savesDir) { saveListUpdates.push([instanceId, savesDir]); },
};
});
it("should create an instance with a server for the installed version", async function() {
const instance = await fromConfig(makeConfig({}));
assert.equal(instance.status, "starting");
assert.equal(instance.server.version, "0.1.2");
assert.equal(instance.server.gamePort, 34100);
assert.deepEqual(sent, [new lib.InstanceStatusChangedEvent(5, "starting", 34100, "latest")]);
assert.deepEqual(requests, ["FactorioVersionsRequest"]);
});
it("should use the configured game port", async function() {
const instance = await fromConfig(makeConfig({ "factorio.game_port": 34200 }));
assert.equal(instance.server.gamePort, 34200);
});
it("should resolve a release channel through the controller", async function() {
const instance = await fromConfig(makeConfig({ "factorio.version": "stable" }));
assert.equal(instance.server.version, "0.1.1");
assert.deepEqual(requests, ["FactorioVersionsRequest", "LatestReleasesRequest"]);
});
it("should load player stats", async function() {
await fs.writeFile(path.join(dir, "instance-stats.json"), JSON.stringify({
players: [["player", { join_count: 2 }]],
player_autosave_slot: 3,
}));
const instance = await fromConfig(makeConfig({}));
assert.equal(instance.playerStats.get("player").joinCount, 2);
assert.equal(instance._playerAutosaveSlot, 3);
});
it("should report the instance stopped if the version is not installed", async function() {
await assert.rejects(fromConfig(makeConfig({ "factorio.version": "0.1.5" })), /0\.1\.5/);
assert.deepEqual(sent, [
new lib.InstanceStatusChangedEvent(5, "starting", 34100, "0.1.5"),
new lib.InstanceStatusChangedEvent(5, "stopped", 34100, "0.1.5"),
]);
assert.deepEqual(saveListUpdates, [[5, path.join(dir, "saves")]]);
});
});
let src;
let dst = addr({ hostId: 1 });
let instance;
@@ -21,8 +95,7 @@ describe("class Instance", function() {
instanceConfig.set("instance.name", "foo");
src = addr({ instanceId: instanceConfig.get("instance.id") });
connector = new MockConnector(src, dst);
instance = new Instance({ assignGamePort: () => 1 }, connector, "dir", "factorioDir", instanceConfig);
instance.server = new MockServer();
instance = new Instance({}, connector, "dir", instanceConfig, new MockServer());
});
describe(".name", function() {
+1 -2
View File
@@ -9,8 +9,7 @@ describe("host/src/export", function() {
let testServer;
before(async function() {
let writePath = path.join("temp", "test", "server");
testServer = new FactorioServer(path.join("test", "file", "factorio"), writePath, {});
await testServer.init();
testServer = await FactorioServer.fromOptions(path.join("test", "file", "factorio"), writePath, {});
});
describe("exportLocale()", function() {
+52 -51
View File
@@ -223,7 +223,10 @@ describe("host/server", function() {
describe("class FactorioServer", function() {
let writePath = path.join("temp", "test", "server");
let server = new hostServer.FactorioServer(path.join("test", "file", "factorio"), writePath, {});
let server;
before(async function() {
server = await hostServer.FactorioServer.fromOptions(path.join("test", "file", "factorio"), writePath, {});
});
describe("constructor()", function() {
it("should handle dashes in write path with strapPaths enabled", function() {
@@ -231,28 +234,37 @@ describe("host/server", function() {
new hostServer.FactorioServer(
path.join("test", "file", "factorio"),
path.join("temp", "test", "server-1"),
{ stripPaths: true }
path.join("test", "file", "factorio", "0.1.2", "data"),
"0.1.2",
{ stripPaths: true, rconPassword: "" }
);
});
});
describe(".setTargetVersion()", function() {
it("should override the target version before init", function() {
const fresh = new hostServer.FactorioServer(
path.join("test", "file", "factorio"), writePath, {}
describe(".fromOptions()", function() {
it("should resolve the requested version", async function() {
const found = await hostServer.FactorioServer.fromOptions(
path.join("test", "file", "factorio"), writePath, { version: "0.1.1" }
);
fresh.setTargetVersion("0.1.5");
assert.equal(fresh._targetVersion, "0.1.5");
assert.equal(found.version, "0.1.1");
assert.equal(found.dataPath(), path.join("test", "file", "factorio", "0.1.1", "data"));
assert.equal(found._state, "init");
});
});
describe(".init()", function() {
it("should not throw on first call", async function() {
await server.init();
it("should generate an RCON password if none is given", async function() {
assert.equal(typeof server.rconPassword, "string");
assert.equal(server.rconPassword.length, 10);
const given = await hostServer.FactorioServer.fromOptions(
path.join("test", "file", "factorio"), writePath, { rconPassword: "secret" }
);
assert.equal(given.rconPassword, "secret");
});
it("should throw if called twice", async function() {
await assert.rejects(server.init(), new Error("Expected state new but state is init"));
it("should reject if the version is not installed", async function() {
await assert.rejects(
hostServer.FactorioServer.fromOptions(
path.join("test", "file", "factorio"), writePath, { version: "0.1.5" }
),
/Unable to find Factorio version 0.1.5/
);
});
});
@@ -314,7 +326,7 @@ describe("host/server", function() {
describe(".handle()", function() {
it("should log validation errors from ipc handlers", async function() {
let logged = [];
let ipcServer = new hostServer.FactorioServer(
let ipcServer = await hostServer.FactorioServer.fromOptions(
path.join("test", "file", "factorio"), writePath,
{ logger: { error: msg => { logged.push(msg); } } }
);
@@ -571,7 +583,7 @@ describe("host/server", function() {
});
});
describe(".checkForUpdates()", function() {
describe("checkForUpdates()", function() {
let _fetch;
let fetchCalledWith;
let _platform = process.platform;
@@ -589,7 +601,6 @@ describe("host/server", function() {
});
afterEach(function() {
global.fetch = _fetch;
server._factorioDir = path.join("test", "file", "factorio");
Object.defineProperty(process, "platform", {
value: _platform,
});
@@ -602,18 +613,17 @@ describe("host/server", function() {
]) {
/* eslint-disable no-loop-func */
describe(suite, function() {
const factorioDir = path.join("test", "file", "factorio");
// A target that matches the 0.1.5 download but not the installed 0.1.1
const newerTarget = target === "0.1.1" ? "0.1.5" : target;
it("should do nothing when there are no versions", async function() {
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target;
await server.checkForUpdates([]);
await hostServer.checkForUpdates(factorioDir, target, []);
assert.equal(fetchCalledWith, null);
});
if (target !== "latest") {
it("should do nothing when no version matches", async function() {
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target;
await server.checkForUpdates([{
await hostServer.checkForUpdates(factorioDir, target, [{
stable: true,
version: "0.2.1",
headlessUrl: "test1",
@@ -627,9 +637,7 @@ describe("host/server", function() {
});
}
it("should do nothing when there is no newer version", async function() {
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target;
await server.checkForUpdates([{
await hostServer.checkForUpdates(factorioDir, target, [{
stable: true,
version: "0.1.1",
headlessUrl: "test1",
@@ -642,9 +650,7 @@ describe("host/server", function() {
assert.equal(fetchCalledWith, null);
});
it("should do nothing for direct installs", async function() {
server._factorioDir = path.join("test", "file", "factorio", "0.1.1");
server._targetVersion = target === "0.1.1" ? "0.1.5" : target;
await server.checkForUpdates([{
await hostServer.checkForUpdates(path.join(factorioDir, "0.1.1"), newerTarget, [{
stable: true,
version: "0.1.5",
headlessUrl: "test1",
@@ -662,12 +668,10 @@ describe("host/server", function() {
});
it("should do nothing when on windows", async function() {
let logLine = null;
server._logger = { info: line => { logLine = line; } };
const logger = { info: line => { logLine = line; } };
Object.defineProperty(process, "platform", { value: "win32" });
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target === "0.1.1" ? "0.1.5" : target;
await server.checkForUpdates([{
await hostServer.checkForUpdates(factorioDir, newerTarget, [{
stable: true,
version: "0.1.5",
headlessUrl: "test1",
@@ -679,7 +683,7 @@ describe("host/server", function() {
stable: false,
version: "0.1.0",
headlessUrl: "test3",
}]);
}], logger);
assert.equal(fetchCalledWith, null);
assert.ok(logLine !== null);
@@ -687,12 +691,10 @@ describe("host/server", function() {
});
it("should do attempt to download on linux", async function() {
let logLine = null;
server._logger = { info: line => { logLine = line; } };
const logger = { info: line => { logLine = line; } };
Object.defineProperty(process, "platform", { value: "linux" });
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target === "0.1.1" ? "0.1.5" : target;
await assert.rejects(server.checkForUpdates([{
await assert.rejects(hostServer.checkForUpdates(factorioDir, newerTarget, [{
stable: true,
version: "0.1.5",
headlessUrl: "test1",
@@ -704,7 +706,7 @@ describe("host/server", function() {
stable: false,
version: "0.1.0",
headlessUrl: "test3",
}]), new Error("Failed to fetch test1: -1 Fetch called"));
}], logger), new Error("Failed to fetch test1: -1 Fetch called"));
assert.equal(fetchCalledWith, "test1");
assert.ok(logLine !== null);
@@ -716,7 +718,7 @@ describe("host/server", function() {
describe("concurrent downloads", function() {
const versions = [{ stable: true, version: "0.1.5", headlessUrl: "test1" }];
// Holds the mocked download open until release() and resolves waiting once a server waits for it
// Holds the mocked download open until release() and resolves waiting once a check waits for it
function holdDownload(factorioDir) {
const held = { fetches: 0 };
const release = new Promise(resolve => { held.release = resolve; });
@@ -731,7 +733,7 @@ describe("host/server", function() {
let waited;
held.waiting = new Promise(resolve => { waited = resolve; });
const logger = { info: line => line.endsWith("waiting for download...") && waited() };
held.servers = [0, 1].map(() => new hostServer.FactorioServer(factorioDir, writePath, { logger }));
held.check = () => hostServer.checkForUpdates(factorioDir, "latest", versions, logger);
Object.defineProperty(process, "platform", { value: "linux" });
return held;
}
@@ -739,7 +741,7 @@ describe("host/server", function() {
it("should download once when servers check at the same time", async function() {
const held = holdDownload(path.join("test", "file", "factorio"));
let settled = 0;
const checks = held.servers.map(s => s.checkForUpdates(versions).finally(() => { settled += 1; }));
const checks = [0, 1].map(() => held.check().finally(() => { settled += 1; }));
await held.waiting;
assert.equal(held.fetches, 1);
@@ -754,14 +756,14 @@ describe("host/server", function() {
await fs.rm(factorioDir, { force: true, recursive: true });
await fs.mkdir(factorioDir, { recursive: true });
const held = holdDownload(factorioDir);
const first = held.servers[0].checkForUpdates(versions);
const first = held.check();
await held.fetching;
// The extraction has written the changelog but has not finished
const dataDir = path.join(factorioDir, "0.1.5", "data");
await fs.mkdir(dataDir, { recursive: true });
await fs.writeFile(path.join(dataDir, "changelog.txt"), "Version: 0.1.5\n");
const second = held.servers[1].checkForUpdates(versions);
const second = held.check();
await held.waiting;
held.release();
@@ -776,12 +778,11 @@ describe("host/server", function() {
this.skip();
}
server._factorioDir = path.join("temp", "test", "factorioDownload");
server._targetVersion = "latest";
const factorioDir = path.join("temp", "test", "factorioDownload");
global.fetch = _fetch;
await fs.rm(server._factorioDir, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(server._factorioDir, { recursive: true });
await server.checkForUpdates([{
await fs.rm(factorioDir, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(factorioDir, { recursive: true });
await hostServer.checkForUpdates(factorioDir, "latest", [{
stable: true,
version: "2.0.73",
headlessUrl: "https://www.factorio.com/get-download/2.0.73/headless/linux64",
+4 -1
View File
@@ -15,7 +15,10 @@ class FakeProcess extends events.EventEmitter {
}
function createStartedServer() {
const server = new FactorioServer(path.join("test", "file", "factorio"), path.join("temp", "test", "server"), {});
const server = new FactorioServer(
path.join("test", "file", "factorio"), path.join("temp", "test", "server"),
path.join("test", "file", "factorio", "0.1.2", "data"), "0.1.2", { rconPassword: "" },
);
server._state = "running";
server._rconReady = false;
server._server = new FakeProcess();
+1 -2
View File
@@ -12,8 +12,7 @@ describe("Integration of lib/factorio/export", function() {
requiresFactorio(this);
it("returns a nested Map with base game locale information", async function() {
let writePath = path.join("temp", "test", "server");
let testServer = new FactorioServer(factorioDir, writePath, {});
await testServer.init();
let testServer = await FactorioServer.fromOptions(factorioDir, writePath, {});
let locale = await _exportLocale(testServer, new Map(), new Map(), ["base"], "en");
+2 -1
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { Host, Instance, InstanceConnection } from "@clusterio/host";
import { ControlConnection, Controller, HostConnection, InstanceRecord } from "@clusterio/controller";
import { MockServer } from "../mock.js";
// Although there are no imports, the file has side-effects such as "before" which can effect tests
import "./index.js";
@@ -39,7 +40,7 @@ function connectInstance(controller, host, instanceId) {
instanceConfig.set("instance.assigned_host", host.config.get("host.id"));
host.assignedInstances.set(instanceId, { path: "invalid", config: instanceConfig });
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
const instance = new Instance(host, instanceSide, "invalid", "invalid", instanceConfig);
const instance = new Instance(host, instanceSide, "invalid", instanceConfig, new MockServer());
const instanceConnection = new InstanceConnection(hostSide, host, instance);
host.instanceConnections.set(instanceId, instanceConnection);
return instance;
+5 -6
View File
@@ -40,12 +40,14 @@ describe("Integration of host/src/server", function() {
describe("class FactorioServer", function() {
let writePath = path.join("temp", "test", "integration");
let server = new FactorioServer(factorioDir, writePath, {});
// Let Factorio bind an OS-assigned UDP port instead of guessing a free one.
server.gamePort = 0;
let server;
let logFile;
before(async function() {
server = await FactorioServer.fromOptions(factorioDir, writePath, {});
// Let Factorio bind an OS-assigned UDP port instead of guessing a free one.
server.gamePort = 0;
// Delete result from previous run of these tests
await fs.rm(writePath, { force: true, recursive: true, maxRetries: 10 });
@@ -63,9 +65,6 @@ describe("Integration of host/src/server", function() {
}
logFile.write(`${JSON.stringify(output)}\n`);
});
// Initialize sever.
await server.init();
});
after(function() {
+29 -21
View File
@@ -50,13 +50,6 @@ describe("lib/datastore", function() {
it("can be constructed", function() {
assert.deepEqual(datastoreProvider.methodCalls[0], ["constructor"]);
});
it("can be bootstrapped", async function() {
const rtn = await datastoreProvider.bootstrap();
assert.equal(rtn[0], datastoreProvider);
assert.equal(rtn[1], datastoreProvider.value);
assert.deepEqual(datastoreProvider.methodCalls[1], ["load"]);
});
});
describe("class MemoryDatastoreProvider", function() {
@@ -70,13 +63,13 @@ describe("lib/datastore", function() {
describe("constructor", function() {
it("has a default value", async function() {
const [, value] = await new lib.MemoryDatastoreProvider().bootstrap();
const value = await new lib.MemoryDatastoreProvider().load();
assert.notEqual(value, undefined);
assert.notEqual(value, null);
});
it("can have a value given", async function() {
const expectedValue = new Map([["foo", "bar"]]);
const [, value] = await new lib.MemoryDatastoreProvider(expectedValue).bootstrap();
const value = await new lib.MemoryDatastoreProvider(expectedValue).load();
assert.deepEqual(value, expectedValue);
});
});
@@ -116,7 +109,7 @@ describe("lib/datastore", function() {
it("has default values", async function() {
datastoreProvider = new lib.JsonDatastoreProvider(dataPath);
const [, value] = await datastoreProvider.bootstrap();
const value = await datastoreProvider.load();
assert.deepEqual(value, new Map(initialData));
});
it("can accept a fromJson method", async function() {
@@ -125,7 +118,7 @@ describe("lib/datastore", function() {
json => [json]
);
const [, value] = await datastoreProvider.bootstrap();
const value = await datastoreProvider.load();
assert.deepEqual(value, new Map(initialData.map(([k, v]) => [k, [v]])));
});
it("can accept a migration method", async function() {
@@ -135,7 +128,7 @@ describe("lib/datastore", function() {
rawJson => ({ c: "foo", ...rawJson })
);
const [, value] = await datastoreProvider.bootstrap();
const value = await datastoreProvider.load();
assert.deepEqual(value, new Map([...initialData, ["c", "foo"]]));
});
it("can accept a finalise method", async function() {
@@ -146,7 +139,7 @@ describe("lib/datastore", function() {
obj => [obj]
);
const [, value] = await datastoreProvider.bootstrap();
const value = await datastoreProvider.load();
assert.deepEqual(value, new Map(initialData.map(([k, v]) => [k, [v]])));
});
});
@@ -210,7 +203,7 @@ describe("lib/datastore", function() {
it("has default values", async function() {
datastoreProvider = new lib.JsonIdDatastoreProvider(dataPath);
const [, value] = await datastoreProvider.bootstrap();
const value = await datastoreProvider.load();
assert.deepEqual(value, new Map(initialData));
});
it("can accept a fromJson method", async function() {
@@ -219,7 +212,7 @@ describe("lib/datastore", function() {
json => ({...json, foo: true})
);
const [, value] = await datastoreProvider.bootstrap();
const value = await datastoreProvider.load();
assert.deepEqual(value, new Map(initialData.map(v => ([v[0], {...v[1], foo: true}]))));
});
it("can accept a migration method", async function() {
@@ -229,7 +222,7 @@ describe("lib/datastore", function() {
rawJson => ([{ id: "c" }, ...rawJson])
);
const [, value] = await datastoreProvider.bootstrap();
const value = await datastoreProvider.load();
assert.deepEqual(value, new Map([...initialData, ["c", { id: "c" }]]));
});
it("can accept a finalise method", async function() {
@@ -240,7 +233,7 @@ describe("lib/datastore", function() {
obj => ({...obj, foo: true})
);
const [, value] = await datastoreProvider.bootstrap();
const value = await datastoreProvider.load();
assert.deepEqual(value, new Map(initialData.map(v => ([v[0], {...v[1], foo: true}]))));
});
});
@@ -306,10 +299,6 @@ describe("lib/datastore", function() {
datastore = new MockDatastore(datastoreProvider, datastoreProvider.value);
assert.deepEqual(datastore.getData(), datastoreProvider.value);
});
it("accepts the return of bootstrap", async function() {
datastore = new MockDatastore(...await datastoreProvider.bootstrap());
assert.deepEqual(datastore.getData(), datastoreProvider.value);
});
});
describe("getFilePath", function() {
@@ -478,6 +467,15 @@ describe("lib/datastore", function() {
datastore.on("update", updates => eventsRaised.push(updates));
});
describe("fromProvider", function() {
it("loads data from the provider", async function() {
datastore = await lib.KeyValueDatastore.fromProvider(datastoreProvider);
assert(datastore instanceof lib.KeyValueDatastore);
assert.equal(datastore.get("foo"), "fooValue");
assert.deepEqual(datastoreProvider.methodCalls[1], ["load"]);
});
});
describe("set", function() {
it("can set a value", function() {
datastore.set("test", "testValue");
@@ -571,6 +569,16 @@ describe("lib/datastore", function() {
datastore.on("update", updates => eventsRaised.push(updates));
});
describe("fromProvider", function() {
it("loads data from the provider", async function() {
datastore = await lib.SubscribableDatastore.fromProvider(datastoreProvider);
assert(datastore instanceof lib.SubscribableDatastore);
assert.equal(datastore.get("foo").id, "foo");
assert.equal(datastore.lastUpdatedAtMs, 10);
assert.deepEqual(datastoreProvider.methodCalls[1], ["load"]);
});
});
function forceFuture() {
const future = {id: "future"};
for (let i = 1; i < 100; i++) {