mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 15:24:52 +09:00
Merge pull request #991 from bbassie/feature/clone-instance-save
Add save copying and start options when creating an instance
This commit is contained in:
8 files changed
+205
-17
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 React, { useContext, useEffect, 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, Typography } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -9,15 +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<typeof useInstances>[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<string, unknown> | 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();
|
||||
@@ -25,19 +55,61 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
form.setFields([{ name: "instanceName", errors: ["Name is required"] }]);
|
||||
return;
|
||||
}
|
||||
const copySave = source !== undefined && hostId !== 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 fields: (keyof lib.InstanceConfigFields)[] = ["instance.id", "instance.name"];
|
||||
if (source && sourceSettings) {
|
||||
if (!values.serverName) {
|
||||
form.setFields([{ name: "serverName", errors: ["Server name is required"] }]);
|
||||
return;
|
||||
}
|
||||
instanceConfig.set("factorio.settings", { ...sourceSettings, name: values.serverName });
|
||||
fields.push("factorio.settings");
|
||||
}
|
||||
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", fields),
|
||||
source?.id,
|
||||
));
|
||||
created = true;
|
||||
|
||||
setOpen(false);
|
||||
navigate(`/instances/${instanceConfig.get("instance.id")}/view`);
|
||||
if (hostId !== undefined) {
|
||||
await control.send(new lib.InstanceAssignRequest(instanceId, hostId));
|
||||
}
|
||||
|
||||
let saveName: string | undefined;
|
||||
if (copySave) {
|
||||
if (saveMode === "save_and_copy") {
|
||||
await control.sendTo({ instanceId: source.id }, new lib.InstanceSaveGameRequest());
|
||||
}
|
||||
saveName = await control.send(new lib.InstanceTransferSaveRequest(
|
||||
source.id, activeSave!.name, instanceId, activeSave!.name, true,
|
||||
));
|
||||
}
|
||||
if (values.startAfter && hostId !== undefined) {
|
||||
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(saveName));
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
if (created) {
|
||||
setOpen(false);
|
||||
navigate(`/instances/${instanceId}/view`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
@@ -51,24 +123,72 @@ 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={{ host: "null", 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)">
|
||||
{canAssign && <Form.Item name="host" label="Host">
|
||||
<Select showSearch optionFilterProp="name">
|
||||
<Select.Option value={"null"}>
|
||||
<Typography.Text italic>Unassigned</Typography.Text>
|
||||
</Select.Option>
|
||||
{[...hosts.values()].sort((a, b) => strcmp(a.name, b.name)).map(h => <Select.Option
|
||||
key={h.id}
|
||||
value={h.id}
|
||||
name={h.name}
|
||||
disabled={!h.connected}
|
||||
>
|
||||
{h.name}
|
||||
{!h.connected && " (offline)"}
|
||||
</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>}
|
||||
<Tooltip title="Perform a one time copy of the config of an existing instance">
|
||||
<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 && sourceSettings && <Tooltip title="Name of the server as shown in the in-game server browser">
|
||||
<Form.Item name="serverName" label="Server Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Tooltip>}
|
||||
{source && hostId !== undefined && <Form.Item name="saveMode" label="Copy Save">
|
||||
<Select
|
||||
options={[
|
||||
{ value: "none", label: "None" },
|
||||
{
|
||||
value: "copy",
|
||||
label: activeSave ? `Copy ${activeSave.name}` : "Copy active save",
|
||||
disabled: !activeSave,
|
||||
},
|
||||
{
|
||||
value: "save_and_copy",
|
||||
label: "Save the running game and copy it",
|
||||
disabled: !sourceRunning || !activeSave,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>}
|
||||
{hostId !== undefined && <Form.Item
|
||||
name="startAfter"
|
||||
valuePropName="checked"
|
||||
extra={saveMode === "none" ? "A new save is created if none is copied" : undefined}
|
||||
>
|
||||
<Checkbox>Start after creation</Checkbox>
|
||||
</Form.Item>}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
|
||||
@@ -19,10 +19,17 @@ export function useInstanceConfig(id?: number) {
|
||||
const [config, setConfig] = useState(undefined as undefined | Static<typeof Config.jsonSchema>);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
control.send(new InstanceConfigGetRequest(id))
|
||||
.then(conf => setConfig(conf));
|
||||
setConfig(undefined);
|
||||
if (id === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Responses for a previous id must not overwrite the current one.
|
||||
let current = true;
|
||||
control.send(new InstanceConfigGetRequest(id))
|
||||
.then(conf => { if (current) { setConfig(conf); } })
|
||||
.catch(() => {}); // Config is optional, the caller falls back to not having it
|
||||
return () => { current = false; };
|
||||
}, [id]);
|
||||
|
||||
return config;
|
||||
|
||||
@@ -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