mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 15:24:52 +09:00
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:
1 parent
e09e1c5f80
commit
c997fd8ea7
7 files changed
+149
-12
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user