"use strict"; const fs = require("fs-extra"); const path = require("path"); const libFileOps = require("@clusterio/lib/file_ops"); const libPlugin = require("@clusterio/lib/plugin"); async function loadDatabase(config, logger) { let itemsPath = path.resolve(config.get("controller.database_directory"), "inventories.json"); logger.verbose(`Loading ${itemsPath}`); try { let content = await fs.readFile(itemsPath); return new Map(JSON.parse(content)); } catch (err) { if (err.code === "ENOENT") { logger.verbose("Creating new player data database"); return new Map(); } throw err; } } async function saveDatabase(controllerConfig, playerDatastore, 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 libFileOps.safeOutputFile(file, content); } } class ControllerPlugin extends libPlugin.BaseControllerPlugin { async init() { this.acquiredPlayers = new Map(); this.playerDatastore = await loadDatabase(this.controller.config, this.logger); this.autosaveId = setInterval(() => { saveDatabase(this.controller.config, this.playerDatastore, this.logger).catch(err => { this.logger.error(`Unexpected error autosaving player data:\n${err.stack}`); }); }, this.controller.config.get("inventory_sync.autosave_interval") * 1000); } async onInstanceStatusChanged(instance) { let instanceId = instance.config.get("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 timeout = this.controller.config.get("inventory_sync.player_lock_timeout") * 1000; for (let acquisitonRecord of this.acquiredPlayers.values()) { if (acquisitonRecord.instanceId === instanceId && !acquisitonRecord.expires) { acquisitonRecord.expires = Date.now() + timeout; } } } if (instance.status === "running") { for (let acquisitonRecord of this.acquiredPlayers.values()) { if (acquisitonRecord.instanceId === instanceId && acquisitonRecord.expires) { delete acquisitonRecord.expires; } } } } acquire(instanceId, playerName) { let acquisitionRecord = this.acquiredPlayers.get(playerName); if ( !acquisitionRecord || acquisitionRecord.instanceId === instanceId || !this.controller.instances.has(acquisitionRecord.instanceId) || acquisitionRecord.expires && acquisitionRecord.expires < Date.now() ) { this.acquiredPlayers.set(playerName, { instanceId }); return true; } return false; } async acquireRequestHandler(message) { let { instance_id, player_name } = message.data; if (!this.acquire(instance_id, player_name)) { let acquisitionRecord = this.acquiredPlayers.get(player_name); let instance = this.controller.instances.get(acquisitionRecord.instanceId); return { status: "busy", message: instance.config.get("instance.name"), }; } let playerData = this.playerDatastore.get(player_name); return { status: "acquired", has_data: Boolean(playerData), generation: playerData ? playerData.generation : 0, }; } async releaseRequestHandler(message) { let { instance_id, player_name } = message.data; let acquisitionRecord = this.acquiredPlayers.get(player_name); if (!acquisitionRecord) { return; } if (acquisitionRecord.instanceId === instance_id) { this.acquiredPlayers.delete(player_name); } } async uploadRequestHandler(message) { let { instance_id, player_name, player_data } = message.data; let instanceName = this.controller.instances.get(instance_id).config.get("instance.name"); let store = true; let acquisitionRecord = this.acquiredPlayers.get(player_name); if (!acquisitionRecord) { this.logger.warn(`${instanceName} uploaded ${player_name} 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 !== instance_id) { this.logger.warn(`${instanceName} uploaded ${player_name} while another instance has acquired it`); store = false; } else { this.acquiredPlayers.delete(player_name); } this.acquiredPlayers.delete(player_name); let oldPlayerData = this.playerDatastore.get(player_name); if (store && oldPlayerData && oldPlayerData.generation >= player_data.generation) { this.logger.warn( `${instanceName} uploaded generation ${player_data.generation} while the stored` + `generation is ${oldPlayerData.generation} for ${player_name}` ); store = false; } if (store) { this.logger.verbose(`Received player data for ${player_name} from ${instanceName}`); this.playerDatastore.set(player_name, player_data); } } async downloadRequestHandler(message) { let { instance_id, player_name } = message.data; let instanceName = this.controller.instances.get(instance_id).config.get("instance.name"); let acquisitionRecord = this.acquiredPlayers.get(player_name); if (!acquisitionRecord) { this.logger.warn(`${instanceName} downloaded ${player_name} without an acquisition`); } else if (acquisitionRecord.instanceId !== instance_id) { this.logger.warn(`${instanceName} downloaded ${player_name} while another instance has acquired it`); } this.logger.verbose(`Sending player data for ${player_name} to ${instanceName}`); return { player_data: this.playerDatastore.get(player_name) || null, }; } async onShutdown() { clearInterval(this.autosaveId); await saveDatabase(this.controller.config, this.playerDatastore, this.logger); } async databaseStatsRequestHandler(message) { let playerDatastore = Array.from(this.playerDatastore.keys()) .map(name => ({ name, length: JSON.stringify(this.playerDatastore.get(name)).length, })) .sort((a, b) => a.length - b.length); return { database_size: playerDatastore.map(x => x.length).reduce((a, b) => b - a, 0), database_entries: playerDatastore.length, largest_entry: { name: playerDatastore[0] && playerDatastore[0].name || "-", size: playerDatastore[0] && playerDatastore[0].length || 0, }, }; } } module.exports = { ControllerPlugin, };