Merge pull request #991 from bbassie/feature/clone-instance-save

Add save copying and start options when creating an instance
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-09-09 20:03:27 +01:00
commit 7987a5d925
8 files changed
+205 -17

No files matched your search

+6
View File
@@ -65,6 +65,12 @@ Terminates the running Factorio server without giving it a chance to save or cle
Note: This may cause loss of data.
### Save game
ctl> instance save-game <name>
Saves the running game of the instance to the save it was started from and waits for the save to finish.
### Extract players from instance
ctl> instance extract-players <name>
+10
View File
@@ -446,6 +446,16 @@ instanceCommands.add(new lib.Command({
},
}));
instanceCommands.add(new lib.Command({
definition: ["save-game <instance>", "Save the running game of an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to save the game on", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.sendTo({ instanceId }, new lib.InstanceSaveGameRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["extract-players <instance>", "Extract players from running save into the cluster.", (yargs) => {
yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" });
+22
View File
@@ -288,6 +288,7 @@ export default class Instance extends lib.Link {
this.handle(lib.InstanceLoadScenarioRequest, this.handleInstanceLoadScenarioRequest.bind(this));
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
this.handle(lib.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this));
this.handle(lib.InstanceSaveGameRequest, this.handleInstanceSaveGameRequest.bind(this));
this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this));
this.handle(lib.InstanceRestartRequest, this.handleInstanceRestartRequest.bind(this));
this.handle(lib.InstanceStopRequest, this.handleInstanceStopRequest.bind(this));
@@ -1307,6 +1308,27 @@ end`.replace(/\r?\n/g, " ");
this.logger.info("Successfully created save");
}
async handleInstanceSaveGameRequest() {
if (this._status !== "running") {
throw new lib.RequestError("Instance is not running");
}
const saved = new Promise<void>((resolve, reject) => {
const onSaved = () => {
this.server.off("exit", onExit);
resolve();
};
const onExit = () => {
this.server.off("save-finished", onSaved);
reject(new lib.RequestError("Instance stopped before the save finished"));
};
this.server.once("save-finished", onSaved);
this.server.once("exit", onExit);
});
await this.sendRcon("/server-save");
await saved;
}
async handleInstanceExportDataRequest() {
this.notifyStatus("exporting_data");
try {
@@ -388,6 +388,14 @@ export class InstanceCreateSaveRequest {
}
}
export class InstanceSaveGameRequest {
declare ["constructor"]: typeof InstanceSaveGameRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.save.create" as const;
}
export class InstanceRenameSaveRequest {
declare ["constructor"]: typeof InstanceRenameSaveRequest;
static type = "request" as const;
+1
View File
@@ -72,6 +72,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
instance.InstanceSaveDetailsListRequest,
instance.InstanceSaveDetailsUpdatesEvent,
instance.InstanceCreateSaveRequest,
instance.InstanceSaveGameRequest,
instance.InstanceRenameSaveRequest,
instance.InstanceCopySaveRequest,
instance.InstanceDeleteSaveRequest,
+134 -14
View File
@@ -1,6 +1,6 @@
import React, { useContext, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal, Select, Space, Tooltip } from "antd";
import { Button, Checkbox, Form, Input, Modal, Select, Space, Tooltip, Typography } from "antd";
import * as lib from "@clusterio/lib";
@@ -9,15 +9,45 @@ import ControlContext from "./ControlContext";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { useInstances } from "../model/instance";
import { useHosts } from "../model/host";
import { useInstanceConfig, useInstances } from "../model/instance";
import { useSaves } from "../model/saves";
import InstanceList from "./InstanceList";
import { notifyErrorHandler } from "../util/notify";
type SaveMode = "none" | "copy" | "save_and_copy";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances>[0] }) {
let control = useContext(ControlContext);
let account = useAccount();
let navigate = useNavigate();
let [open, setOpen] = useState(false);
let [creating, setCreating] = useState(false);
let [form] = Form.useForm();
let [hosts] = useHosts();
let [saves] = useSaves();
const canAssign = account.hasPermission("core.instance.assign");
const cloneId: number | undefined = Form.useWatch("instanceClone", form);
const host: number | string | undefined = Form.useWatch("host", form);
const saveMode: SaveMode = Form.useWatch("saveMode", form) ?? "none";
const hostId = typeof host === "number" ? host : undefined;
const source = cloneId !== undefined && cloneId >= 0 ? props.instances.get(cloneId) : undefined;
const sourceRunning = source?.status === "running";
const activeSave = source ? [...saves.values()].find(
save => save.instanceId === source.id && (sourceRunning ? save.loaded : save.loadByDefault)
) : undefined;
const sourceConfig = useInstanceConfig(source?.id);
const sourceSettings = sourceConfig?.["factorio.settings"] as Record<string, unknown> | undefined;
const sourceServerName = typeof sourceSettings?.name === "string" ? sourceSettings.name : undefined;
useEffect(() => {
if (open && sourceServerName !== undefined) {
form.setFieldValue("serverName", sourceServerName);
}
}, [open, sourceServerName]);
async function createInstance() {
let values = form.getFieldsValue();
@@ -25,19 +55,61 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
form.setFields([{ name: "instanceName", errors: ["Name is required"] }]);
return;
}
const copySave = source !== undefined && hostId !== undefined && saveMode !== "none";
if (copySave && !activeSave) {
form.setFields([{ name: "saveMode", errors: ["The source instance has no save to copy"] }]);
return;
}
if (copySave && source.assignedHost === undefined) {
form.setFields([{ name: "saveMode", errors: ["The source instance is not assigned to a host"] }]);
return;
}
let instanceConfig = new lib.InstanceConfig("control");
instanceConfig.set("instance.name", values.instanceName);
const fields: (keyof lib.InstanceConfigFields)[] = ["instance.id", "instance.name"];
if (source && sourceSettings) {
if (!values.serverName) {
form.setFields([{ name: "serverName", errors: ["Server name is required"] }]);
return;
}
instanceConfig.set("factorio.settings", { ...sourceSettings, name: values.serverName });
fields.push("factorio.settings");
}
const instanceId = instanceConfig.get("instance.id");
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
values.instanceClone >= 0 ? values.instanceClone : undefined,
));
setCreating(true);
let created = false;
try {
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", fields),
source?.id,
));
created = true;
setOpen(false);
navigate(`/instances/${instanceConfig.get("instance.id")}/view`);
if (hostId !== undefined) {
await control.send(new lib.InstanceAssignRequest(instanceId, hostId));
}
let saveName: string | undefined;
if (copySave) {
if (saveMode === "save_and_copy") {
await control.sendTo({ instanceId: source.id }, new lib.InstanceSaveGameRequest());
}
saveName = await control.send(new lib.InstanceTransferSaveRequest(
source.id, activeSave!.name, instanceId, activeSave!.name, true,
));
}
if (values.startAfter && hostId !== undefined) {
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(saveName));
}
} finally {
setCreating(false);
if (created) {
setOpen(false);
navigate(`/instances/${instanceId}/view`);
}
}
}
return <>
@@ -51,24 +123,72 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
title="Create Instance"
okText="Create"
open={open}
confirmLoading={creating}
onOk={() => { createInstance().catch(notifyErrorHandler("Error creating instance")); }}
onCancel={() => { setOpen(false); }}
destroyOnHidden
>
<Form form={form}>
<Form
form={form}
initialValues={{ host: "null", instanceClone: -1, saveMode: "none", startAfter: false }}
>
<Form.Item name="instanceName" label="Name">
<Input />
</Form.Item>
<Tooltip title="Perform a one time copy of an existing config (assigned host is not copied)">
{canAssign && <Form.Item name="host" label="Host">
<Select showSearch optionFilterProp="name">
<Select.Option value={"null"}>
<Typography.Text italic>Unassigned</Typography.Text>
</Select.Option>
{[...hosts.values()].sort((a, b) => strcmp(a.name, b.name)).map(h => <Select.Option
key={h.id}
value={h.id}
name={h.name}
disabled={!h.connected}
>
{h.name}
{!h.connected && " (offline)"}
</Select.Option>)}
</Select>
</Form.Item>}
<Tooltip title="Perform a one time copy of the config of an existing instance">
<Form.Item name="instanceClone" label="Copy Config">
<Select
defaultValue={-1}
options={[{ id: -1, name: "Default Config" }, ...props.instances.values()]
.map(i => ({ value: i.id, label: i.name }))
}
/>
</Form.Item>
</Tooltip>
{source && sourceSettings && <Tooltip title="Name of the server as shown in the in-game server browser">
<Form.Item name="serverName" label="Server Name">
<Input />
</Form.Item>
</Tooltip>}
{source && hostId !== undefined && <Form.Item name="saveMode" label="Copy Save">
<Select
options={[
{ value: "none", label: "None" },
{
value: "copy",
label: activeSave ? `Copy ${activeSave.name}` : "Copy active save",
disabled: !activeSave,
},
{
value: "save_and_copy",
label: "Save the running game and copy it",
disabled: !sourceRunning || !activeSave,
},
]}
/>
</Form.Item>}
{hostId !== undefined && <Form.Item
name="startAfter"
valuePropName="checked"
extra={saveMode === "none" ? "A new save is created if none is copied" : undefined}
>
<Checkbox>Start after creation</Checkbox>
</Form.Item>}
</Form>
</Modal>
</>;
+10 -3
View File
@@ -19,10 +19,17 @@ export function useInstanceConfig(id?: number) {
const [config, setConfig] = useState(undefined as undefined | Static<typeof Config.jsonSchema>);
useEffect(() => {
if (id) {
control.send(new InstanceConfigGetRequest(id))
.then(conf => setConfig(conf));
setConfig(undefined);
if (id === undefined) {
return undefined;
}
// Responses for a previous id must not overwrite the current one.
let current = true;
control.send(new InstanceConfigGetRequest(id))
.then(conf => { if (current) { setConfig(conf); } })
.catch(() => {}); // Config is optional, the caller falls back to not having it
return () => { current = false; };
}, [id]);
return config;
+14
View File
@@ -914,6 +914,20 @@ describe("Integration of Clusterio", function() {
}
assert(received, "InstanceSaveDetailsUpdatesEvent not sent");
});
it("should save the running game with save-game", async function() {
slowTest(this);
getControl().saveUpdates = [];
await execCtl("instance save-game test");
let received = false;
for (let x = 0; x < 10; x++) {
if (getControl().saveUpdates.length) {
received = true;
break;
}
await wait(100);
}
assert(received, "InstanceSaveDetailsUpdatesEvent not sent");
});
it("should prevent execution of script commands when disabled", async function() {
slowTest(this);
await execCtl("instance send-rcon test /c");