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] 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");