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