Add save copying and start options when creating an instance

The create instance modal can now copy the active save of the instance
whose config is copied, optionally saving the running game first, and
start the new instance once the copy is done. The new instance is
assigned to the host of the source so the save can be transferred.

Saving a running game is a new InstanceSaveGameRequest handled by the
host through /server-save, which waits for the save to finish. It is
also exposed as clusterioctl instance save-game.

Closes #405

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Fable 5.1 committed 2026-09-05 22:19:24 +00:00
1 parent e09e1c5f80
commit c997fd8ea7
7 files changed
+149 -12

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,
@@ -1,6 +1,6 @@
import React, { useContext, 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 } from "antd";
import * as lib from "@clusterio/lib";
@@ -10,14 +10,27 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { 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";
function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances>[0] }) {
let control = useContext(ControlContext);
let navigate = useNavigate();
let [open, setOpen] = useState(false);
let [creating, setCreating] = useState(false);
let [form] = Form.useForm();
let [saves] = useSaves();
const cloneId: number | undefined = Form.useWatch("instanceClone", form);
const saveMode: SaveMode = Form.useWatch("saveMode", form) ?? "none";
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;
async function createInstance() {
let values = form.getFieldsValue();
@@ -25,19 +38,50 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
form.setFields([{ name: "instanceName", errors: ["Name is required"] }]);
return;
}
const copySave = source !== 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 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", [
"instance.id", "instance.name",
]),
source?.id,
));
created = true;
setOpen(false);
navigate(`/instances/${instanceConfig.get("instance.id")}/view`);
if (copySave) {
await control.send(new lib.InstanceAssignRequest(instanceId, source.assignedHost));
if (saveMode === "save_and_copy") {
await control.sendTo({ instanceId: source.id }, new lib.InstanceSaveGameRequest());
}
const saveName = await control.send(new lib.InstanceTransferSaveRequest(
source.id, activeSave!.name, instanceId, activeSave!.name, true,
));
if (values.startAfter) {
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(saveName));
}
}
} finally {
setCreating(false);
if (created) {
setOpen(false);
navigate(`/instances/${instanceId}/view`);
}
}
}
return <>
@@ -51,24 +95,56 @@ 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={{ 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)">
<Tooltip
title="Perform a one time copy of an existing config (the host is only copied along with a save)"
>
<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 && <>
<Form.Item
name="saveMode"
label="Copy Save"
extra={
saveMode !== "none" ? "The new instance is assigned to the host of the source" : undefined
}
>
<Select
options={[
{ value: "none", label: "None" },
{
value: "copy",
label: activeSave ? `Copy ${activeSave.name}` : "Copy active save",
disabled: !activeSave,
},
{
value: "save_and_copy",
label: activeSave
? `Save the running game and copy ${activeSave.name}`
: "Save and copy",
disabled: !sourceRunning || !activeSave,
},
]}
/>
</Form.Item>
<Form.Item name="startAfter" valuePropName="checked">
<Checkbox disabled={saveMode === "none"}>Start after creation</Checkbox>
</Form.Item>
</>}
</Form>
</Modal>
</>;
+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");