@clusterio/plugin-inventory_sync
Version:
Clusterio plugin forwarding between Factorio servers
202 lines (175 loc) • 7.15 kB
text/typescript
import { BaseControllerPlugin, type InstanceInfo } from "@clusterio/controller";
import type { IpcPlayerData } from "./messages";
import fs from "fs-extra";
import path from "path";
import * as lib from "@clusterio/lib";
import * as msg from "./messages";
async function loadDatabase(config: lib.ControllerConfig, logger: lib.Logger) {
let itemsPath = path.resolve(config.get("controller.database_directory"), "inventories.json");
logger.verbose(`Loading ${itemsPath}`);
try {
let content = await fs.readFile(itemsPath, { encoding: "utf8" });
return new Map(JSON.parse(content));
} catch (err: any) {
if (err.code === "ENOENT") {
logger.verbose("Creating new player data database");
return new Map();
}
throw err;
}
}
async function saveDatabase(
controllerConfig: lib.ControllerConfig,
playerDatastore: Map<string, IpcPlayerData> | undefined,
logger: lib.Logger,
) {
if (playerDatastore) {
let file = path.resolve(controllerConfig.get("controller.database_directory"), "inventories.json");
logger.verbose(`writing ${file}`);
let content = JSON.stringify(Array.from(playerDatastore));
await lib.safeOutputFile(file, content);
}
}
export class ControllerPlugin extends BaseControllerPlugin {
acquiredPlayers!: Map<string, { instanceId: number, expiresMs?: number }>;
playerDatastore!: Map<string, IpcPlayerData>;
playerDatastoreDirty = false;
async init() {
this.acquiredPlayers = new Map();
this.playerDatastore = await loadDatabase(this.controller.config, this.logger);
this.controller.handle(msg.AcquireRequest, this.handleAcquireRequest.bind(this));
this.controller.handle(msg.ReleaseRequest, this.handleReleaseRequest.bind(this));
this.controller.handle(msg.UploadRequest, this.handleUploadRequest.bind(this));
this.controller.handle(msg.DownloadRequest, this.handleDownloadRequest.bind(this));
this.controller.handle(msg.DatabaseStatsRequest, this.handleDatabaseStatsRequest.bind(this));
}
async onInstanceStatusChanged(instance: InstanceInfo) {
let instanceId = instance.id;
if (["unassigned", "deleted"].includes(instance.status)) {
for (let [playerName, acquisitionRecord] of this.acquiredPlayers) {
if (acquisitionRecord.instanceId === instanceId) {
this.acquiredPlayers.delete(playerName);
}
}
}
if (["unknown", "stopped"].includes(instance.status)) {
let timeoutMs = this.controller.config.get("inventory_sync.player_lock_timeout") * 1000;
for (let acquisitonRecord of this.acquiredPlayers.values()) {
if (acquisitonRecord.instanceId === instanceId && !acquisitonRecord.expiresMs) {
acquisitonRecord.expiresMs = Date.now() + timeoutMs;
}
}
}
if (instance.status === "running") {
for (let acquisitonRecord of this.acquiredPlayers.values()) {
if (acquisitonRecord.instanceId === instanceId && acquisitonRecord.expiresMs) {
delete acquisitonRecord.expiresMs;
}
}
}
}
acquire(instanceId: number, playerName: string): boolean {
let acquisitionRecord = this.acquiredPlayers.get(playerName);
if (
!acquisitionRecord
|| acquisitionRecord.instanceId === instanceId
|| !this.controller.instances.has(acquisitionRecord.instanceId)
|| acquisitionRecord.expiresMs && acquisitionRecord.expiresMs < Date.now()
) {
this.acquiredPlayers.set(playerName, { instanceId });
return true;
}
return false;
}
async handleAcquireRequest(request: msg.AcquireRequest) {
let { instanceId, playerName } = request;
if (!this.acquire(instanceId, playerName)) {
let acquisitionRecord = this.acquiredPlayers.get(playerName);
let instance = this.controller.instances.get(acquisitionRecord!.instanceId)!;
return {
status: "busy",
message: instance.config.get("instance.name"),
};
}
let playerData = this.playerDatastore.get(playerName);
return new msg.AcquireRequest.Response(
"acquired",
playerData ? playerData.generation : 0,
Boolean(playerData),
);
}
async handleReleaseRequest(request: msg.ReleaseRequest) {
let { instanceId, playerName } = request;
let acquisitionRecord = this.acquiredPlayers.get(playerName);
if (!acquisitionRecord) {
return;
}
if (acquisitionRecord.instanceId === instanceId) {
this.acquiredPlayers.delete(playerName);
}
}
async handleUploadRequest(request: msg.UploadRequest) {
let { instanceId, playerName, playerData } = request;
let instanceName = this.controller.instances.get(instanceId)!.config.get("instance.name");
let store = true;
let acquisitionRecord = this.acquiredPlayers.get(playerName);
if (!acquisitionRecord) {
this.logger.warn(`${instanceName} uploaded ${playerName} without an acquisition`);
// Allow upload in this case as it might come from a crashed instance that restarted and is now
// uploading the player data for all the players that were online during the last autosave.
} else if (acquisitionRecord.instanceId !== instanceId) {
this.logger.warn(`${instanceName} uploaded ${playerName} while another instance has acquired it`);
store = false;
} else {
this.acquiredPlayers.delete(playerName);
}
this.acquiredPlayers.delete(playerName);
let oldPlayerData = this.playerDatastore.get(playerName);
if (store && oldPlayerData && oldPlayerData.generation >= playerData.generation) {
this.logger.warn(
`${instanceName} uploaded generation ${playerData.generation} while the stored` +
`generation is ${oldPlayerData.generation} for ${playerName}`
);
store = false;
}
if (store) {
this.logger.verbose(`Received player data for ${playerName} from ${instanceName}`);
this.playerDatastore.set(playerName, playerData);
this.playerDatastoreDirty = true;
}
}
async handleDownloadRequest(request: msg.DownloadRequest) {
let { instanceId, playerName } = request;
let instanceName = this.controller.instances.get(instanceId)!.config.get("instance.name");
let acquisitionRecord = this.acquiredPlayers.get(playerName);
if (!acquisitionRecord) {
this.logger.warn(`${instanceName} downloaded ${playerName} without an acquisition`);
} else if (acquisitionRecord.instanceId !== instanceId) {
this.logger.warn(`${instanceName} downloaded ${playerName} while another instance has acquired it`);
}
this.logger.verbose(`Sending player data for ${playerName} to ${instanceName}`);
return new msg.DownloadRequest.Response(this.playerDatastore.get(playerName) || null);
}
async onSaveData() {
if (this.playerDatastoreDirty) {
this.playerDatastoreDirty = false;
await saveDatabase(this.controller.config, this.playerDatastore, this.logger);
}
}
async handleDatabaseStatsRequest() {
let playerDatastore = Array.from(this.playerDatastore.keys())
.map(name => ({
name,
length: JSON.stringify(this.playerDatastore.get(name)).length,
}))
.sort((a, b) => b.length - a.length);
return new msg.DatabaseStatsRequest.Response(
playerDatastore.map(x => x.length).reduce((acc, val) => acc + val, 0),
playerDatastore.length,
{
name: playerDatastore[0] && playerDatastore[0].name || "-",
size: playerDatastore[0] && playerDatastore[0].length || 0,
},
);
}
}