From c997fd8ea7c4839a75c955d71c3721331c4c4310 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:19:24 +0000 Subject: [PATCH 1/2] 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 --- docs/managing-a-cluster.md | 6 ++ packages/ctl/src/commands_instance.ts | 10 ++ packages/host/src/Instance.ts | 22 ++++ packages/lib/src/data/messages_instance.ts | 8 ++ packages/lib/src/link/messages.ts | 1 + .../web_ui/src/components/InstancesPage.tsx | 100 +++++++++++++++--- test/integration/clusterio.js | 14 +++ 7 files changed, 149 insertions(+), 12 deletions(-) diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index 5dac03b2..476d1314 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -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 + +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 diff --git a/packages/ctl/src/commands_instance.ts b/packages/ctl/src/commands_instance.ts index fd880ba7..173451b4 100644 --- a/packages/ctl/src/commands_instance.ts +++ b/packages/ctl/src/commands_instance.ts @@ -446,6 +446,16 @@ instanceCommands.add(new lib.Command({ }, })); +instanceCommands.add(new lib.Command({ + definition: ["save-game ", "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 ", "Extract players from running save into the cluster.", (yargs) => { yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" }); diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 19f10032..3e45f04d 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -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((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 { diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index 0b87461b..51bcd0db 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -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; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index d5aaa4d3..1db48338 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -72,6 +72,7 @@ export const dataClasses: (RequestClass | EventClass) instance.InstanceSaveDetailsListRequest, instance.InstanceSaveDetailsUpdatesEvent, instance.InstanceCreateSaveRequest, + instance.InstanceSaveGameRequest, instance.InstanceRenameSaveRequest, instance.InstanceCopySaveRequest, instance.InstanceDeleteSaveRequest, diff --git a/packages/web_ui/src/components/InstancesPage.tsx b/packages/web_ui/src/components/InstancesPage.tsx index b62f6970..ec5fe7b2 100644 --- a/packages/web_ui/src/components/InstancesPage.tsx +++ b/packages/web_ui/src/components/InstancesPage.tsx @@ -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[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= 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 { createInstance().catch(notifyErrorHandler("Error creating instance")); }} onCancel={() => { setOpen(false); }} destroyOnHidden > -
+ - + + + + Start after creation + + } ; diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index aa13282d..26d8dfa9 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -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"); From 9b17fa2b93be503778be68eae2bc0664d0132428 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:39:39 +0000 Subject: [PATCH 2/2] Add host, server name and start options to the create instance modal Accounts with core.instance.assign get a host selector, defaulting to unassigned. Picking a host offers "Start after creation", which creates a new save on first run when no save is copied, and together with a config to copy it offers "Copy Save". Copying a config also offers a server name, prefilled from the source, which overwrites factorio.settings.name so the clone does not show up under the source name in the server browser. useInstanceConfig now clears the config while loading and drops errors, so an account without core.instance.get_config just does not get the server name field. Co-Authored-By: Claude Opus 5 (1M context) --- .../web_ui/src/components/InstancesPage.tsx | 134 ++++++++++++------ packages/web_ui/src/model/instance.ts | 13 +- 2 files changed, 99 insertions(+), 48 deletions(-) diff --git a/packages/web_ui/src/components/InstancesPage.tsx b/packages/web_ui/src/components/InstancesPage.tsx index ec5fe7b2..ed1498a2 100644 --- a/packages/web_ui/src/components/InstancesPage.tsx +++ b/packages/web_ui/src/components/InstancesPage.tsx @@ -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, Checkbox, 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,28 +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[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 | 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(); @@ -38,7 +55,7 @@ function CreateInstanceButton(props: { instances: ReturnType { setOpen(false); }} destroyOnHidden > -
+ - + {canAssign && + + } + + {source && sourceSettings && + + - - Start after creation - - } + } + {source && hostId !== undefined && +