Files
factorio-clusterio/plugins/player_sync/instance.ts
T
bbassieandClaude Opus 5.5 f12b016cec Invalidate instance copies when player data is imported or deleted
Generations alone could not order a repair against copies held by
instances. Deleting dropped the watermark, so a stale instance could
skip downloading the new baseline. An import could land on the same
generation as an upload that never reached the controller, and the
instance would then keep its own copy.

Imports and deletes now bump a per-player epoch, which the controller
returns on acquire and checks on upload. Instances throw away a copy
from an earlier epoch, and deleted players keep their generation and
epoch in player_sync_deleted.json. A restarted download also takes the
new generation instead of the outdated one.

Generations in player data must now be non-negative integers, so an
invalid import is refused before it reaches the datastore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:52:54 +00:00

294 lines
8.9 KiB
TypeScript

import util from "util";
import zlib from "zlib";
import * as lib from "@clusterio/lib";
import type { InstancePluginContext } from "@clusterio/host";
import {
AcquireRequest, AcquireResponse, ReleaseRequest, UploadRequest, DownloadRequest, DownloadResponse, IpcPlayerData,
} from "./messages.js";
import { type Component, enabledComponents, stripPlayerData } from "./components.js";
type IpcPlayerName = {
player_name: string
}
type IpcDownloadRequest = {
player_name: string,
recipe_notifications?: string,
}
/** Components as an escaped JSON object for the scenario. */
function componentsJson(enabled: Set<Component>) {
return lib.escapeString(JSON.stringify(Object.fromEntries([...enabled].map(name => [name, true]))));
}
type IpcAcquireResponse = {
player_name: string,
status: string,
generation?: number,
has_data?: boolean,
message?: string,
epoch?: number,
}
/**
* Splits string into array of strings with max of a certain length
* @param chunkSize - Max length of each chunk
* @param string - String to split into chunks
* @returns array of substrings
*/
function chunkify(chunkSize: number, string: string): string[] {
return string.match(new RegExp(`.{1,${chunkSize}}`, "g")) || [];
}
const inflate = util.promisify(zlib.inflate);
const deflate = util.promisify(zlib.deflate);
/**
* Decode a string produced by helpers.encode_string in Factorio
*/
async function decodeLuaString(encoded: string): Promise<string> {
return (await inflate(Buffer.from(encoded, "base64"))).toString("utf8");
}
/**
* Encode a string so that helpers.decode_string in Factorio can read it
*/
async function encodeLuaString(text: string): Promise<string> {
return (await deflate(Buffer.from(text, "utf8"))).toString("base64");
}
export type RecipeNotificationDelta = {
add?: string[],
remove?: string[],
}
/**
* Compute the changes needed to turn the current cleared recipe list into the stored one
* @param stored - Cleared recipe names stored on the controller
* @param current - Cleared recipe names the instance currently has
* @returns names to add to and remove from the current list
*/
export function recipeNotificationDelta(stored: string[], current: string[]): RecipeNotificationDelta {
const storedSet = new Set(stored);
const currentSet = new Set(current);
const delta: RecipeNotificationDelta = {};
const add = stored.filter(name => !currentSet.has(name));
const remove = current.filter(name => !storedSet.has(name));
if (add.length) {
delta.add = add;
}
if (remove.length) {
delta.remove = remove;
}
return delta;
}
/**
* Replace the stored recipe notifications with the difference from the
* current state on the instance to reduce the amount of data sent to Lua.
*/
export async function applyRecipeNotificationDelta(
playerData: IpcPlayerData,
current: string | undefined,
logger: lib.Logger,
) {
if (!playerData.recipe_notifications) {
return;
}
try {
const delta = recipeNotificationDelta(
JSON.parse(await decodeLuaString(playerData.recipe_notifications)),
current ? JSON.parse(await decodeLuaString(current)) : [],
);
playerData.recipe_notifications = await encodeLuaString(JSON.stringify(delta));
} catch (err: any) {
logger.warn(`Dropping invalid recipe notifications for ${playerData.name}:\n${err.stack}`);
delete playerData.recipe_notifications;
}
}
export default async function(context: InstancePluginContext) {
const { instance, host, logger, plugin } = context;
const playersToRelease = new Set<string>();
let disconnecting = false;
async function handleAcquire(request: IpcPlayerName) {
let response: IpcAcquireResponse = {
player_name: request.player_name,
status: "error",
message: "Controller is temporarily unavailable",
has_data: undefined,
generation: undefined,
epoch: undefined,
};
if (host.connector.connected && !disconnecting) {
try {
let acquireResponse: AcquireResponse = await instance.sendTo(
"controller",
new AcquireRequest(instance.id, request.player_name),
);
response = {
player_name: request.player_name,
status: acquireResponse.status,
generation: acquireResponse.generation,
has_data: acquireResponse.hasData,
message: acquireResponse.message,
epoch: acquireResponse.epoch,
};
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
logger.error(`Unexpected error sending aquire request:\n${err.stack}`);
response.message = err.message;
}
}
}
let json = lib.escapeString(JSON.stringify(response));
await instance.sendRcon(`/sc player_sync.acquire_response("${json}")`, true, plugin.name);
}
async function handleRelease(request: IpcPlayerName) {
if (!host.connector.connected) {
playersToRelease.add(request.player_name);
}
try {
await instance.sendTo(
"controller",
new ReleaseRequest(instance.id, request.player_name)
);
} catch (err: any) {
if (err instanceof lib.SessionLost) {
playersToRelease.add(request.player_name);
} else {
logger.error(`Unexpected error releasing player ${request.player_name}:\n${err.stack}`);
}
}
}
async function handleUpload(player_data: IpcPlayerData) {
if (!host.connector.connected || disconnecting) {
return;
}
logger.verbose(`Uploading ${player_data.name} (${JSON.stringify(player_data).length / 1000}kB)`);
try {
await instance.sendTo(
"controller",
new UploadRequest(instance.id, player_data.name, player_data),
);
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
logger.error(`Unexpected error uploading inventory for ${player_data.name}:\n${err.stack}`);
}
return;
}
await instance.sendRcon(
`/sc player_sync.confirm_upload("${player_data.name}", ${player_data.generation})`, true, plugin.name
);
}
async function handleDownload(request: IpcDownloadRequest) {
const playerName = request.player_name;
logger.verbose(`Downloading ${playerName}`);
let response: DownloadResponse = await instance.sendTo(
"controller",
new DownloadRequest(instance.id, playerName)
);
if (!response.playerData) {
await instance.sendRcon(
`/sc player_sync.download_inventory('${playerName}',nil,0,0)`, true, plugin.name
);
return;
}
const enabled = enabledComponents(instance.config);
stripPlayerData(response.playerData, enabled);
await applyRecipeNotificationDelta(response.playerData, request.recipe_notifications, logger);
const chunkSize = instance.config.get("player_sync.rcon_chunk_size");
const chunks = chunkify(chunkSize, JSON.stringify(response.playerData));
logger.verbose(`Sending inventory for ${playerName} in ${chunks.length} chunks`);
// The scenario restarts the download if it was started with other components
const components = componentsJson(enabled);
for (let i = 0; i < chunks.length; i++) {
const chunk = lib.escapeString(chunks[i]);
const extra = i === 0 ? `,'${components}'` : "";
await instance.sendRcon(
`/sc player_sync.download_inventory('${playerName}','${chunk}',${i + 1},${chunks.length}${extra})`,
true,
plugin.name,
);
}
}
// Handle IPC from scenario script
instance.server.on(
"ipc-player_sync_acquire",
(request: IpcPlayerName) => handleAcquire(request).catch(
err => logger.error(`Error handling ipc-player_sync_acquire:\n${err.stack}`)
),
);
instance.server.on(
"ipc-player_sync_release",
(request: IpcPlayerName) => handleRelease(request).catch(
err => logger.error(`Error handling ipc-player_sync_release:\n${err.stack}`)
),
);
instance.server.on(
"ipc-player_sync_upload",
(player_data: IpcPlayerData) => handleUpload(player_data).catch(
err => logger.error(`Error handling ipc-player_sync_upload:\n${err.stack}`)
),
);
instance.server.on(
"ipc-player_sync_download",
(request: IpcDownloadRequest) => handleDownload(request).catch(
err => logger.error(`Error handling ipc-player_sync_download:\n${err.stack}`)
),
);
/** Tell the scenario which components are synced. */
async function sendComponents() {
const json = componentsJson(enabledComponents(instance.config));
await instance.sendRcon(`/sc player_sync.set_components("${json}")`, true, plugin.name);
}
instance.hooks.start.attach(plugin.name, sendComponents);
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field) => {
if (field.startsWith("player_sync.sync_") && instance.status === "running") {
await sendComponents();
}
});
instance.hooks.prepareControllerDisconnect.attach(plugin.name, async () => {
disconnecting = true;
});
instance.hooks.controllerConnectionEvent.attach(plugin.name, async (event) => {
if (event === "connect") {
disconnecting = false;
(async () => {
for (let player_name of playersToRelease) {
if (!host.connector.connected || disconnecting) {
return;
}
playersToRelease.delete(player_name);
await instance.sendTo(
"controller",
new ReleaseRequest(instance.id, player_name)
);
}
})().catch(
err => logger.error(`Unpexpected error releasing queued up players:\n${err.stack}`)
);
}
});
}