mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
464d588b5e | ||
|
|
0ddc1899fa | ||
|
|
4e1116ae2d | ||
|
|
3cf9f96233 | ||
|
|
c37fe0f438 | ||
|
|
8831b33c01 | ||
|
|
56124574fb | ||
|
|
9b416583f0 | ||
|
|
b885e37b5b | ||
|
|
7e7e763517 | ||
|
|
811c4f7127 | ||
|
|
f0fb471ea1 | ||
|
|
79c1a2dc99 | ||
|
|
a8ce8e7cc0 | ||
|
|
50687515fa | ||
|
|
6f81216e7c | ||
|
|
3423e0a30c | ||
|
|
8fbdb5dfab | ||
|
|
7625fd82e3 | ||
|
|
5dcc66034b | ||
|
|
a66b1af454 | ||
|
|
3adfcf0a29 | ||
|
|
9a4241e4cd | ||
|
|
8dcb2197d6 | ||
|
|
a168f7db5e | ||
|
|
283aba70df | ||
|
|
62abfa903b | ||
|
|
b7fec4c700 | ||
|
|
ec19d5456e | ||
|
|
e5d82dcfde | ||
|
|
a68bb9c0a8 | ||
|
|
2075814ed1 | ||
|
|
f07ee22969 | ||
|
|
a70490648f | ||
|
|
e3abf8ba33 | ||
|
|
46309ce5b8 | ||
|
|
f392b258f7 | ||
|
|
1afee2ede5 | ||
|
|
0b2fcdcec4 | ||
|
|
735faf4b2c | ||
|
|
d2775e4db9 | ||
|
|
b3fc278b2c | ||
|
|
58231cc02c | ||
|
|
2916dcc58c | ||
|
|
b9a2d6b7bd | ||
|
|
224a451bbd | ||
|
|
b222f6dcb1 | ||
|
|
9b0293501f | ||
|
|
de00a043c3 | ||
|
|
396e2c59c7 | ||
|
|
c3b2c846b7 | ||
|
|
be8bba8ff8 | ||
|
|
c81d2b61a6 | ||
|
|
534dc14221 | ||
|
|
fcf174376d | ||
|
|
0e68596fbd | ||
|
|
9bcbc1e7f3 |
No files matched your search
@@ -44,6 +44,7 @@ module.exports = {
|
||||
"files": [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
"env": {
|
||||
@@ -94,6 +95,19 @@ module.exports = {
|
||||
"node/shebang": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/create/templates/**/*.{t,j}s?(x)",
|
||||
],
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
"rules": {
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
<!-- Template for entries. Add sections that apply as changes are made.
|
||||
## Version 2.0.0-alpha.xx
|
||||
|
||||
### Major Features
|
||||
|
||||
- Major additions and/or major improvements to managing clusters.
|
||||
|
||||
### Features
|
||||
|
||||
- New features added.
|
||||
|
||||
### Changes
|
||||
|
||||
- Bugfixes and small changes to existing behaviour.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changes which break backwards compatibility with older hosts or plugins code.
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.0-alpha.18
|
||||
|
||||
### Features
|
||||
|
||||
- Added logging of HTTP requests and errors on the controller [#556](https://github.com/clusterio/clusterio/pull/556).
|
||||
- Added caching of data parsed from mods to speed up startup time [#606](https://github.com/clusterio/clusterio/pull/606).
|
||||
- Added command to installer to generate the code for making a new plugin [#570](https://github.com/clusterio/clusterio/pull/570).
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed Inventory Sync reporting a negative database size [#598](https://github.com/clusterio/clusterio/pull/598).
|
||||
- Fixed plugin events being invoked while a host connection was in an invalid state.
|
||||
- Suppresed bogus warning about event listener leak in the browser's console [#600](https://github.com/clusterio/clusterio/pull/600).
|
||||
- Fixed installer breaking on Windows after Node.js released a security fix [#614](https://github.com/clusterio/clusterio/pull/614).
|
||||
- During a data export the mod settings in mod packs will now be corrected to the type of the settings prototype if the type is incorrect.
|
||||
- Fixed missing color setting causing the Mod Pack view in the Web UI to show an error [#609](https://github.com/clusterio/clusterio/issues/609).
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Danielv123](https://github.com/Danielv123)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
|
||||
|
||||
## Version 2.0.0-alpha.17
|
||||
|
||||
### Features
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
if_ci_failed: success
|
||||
informational: true
|
||||
+114
-139
@@ -12,8 +12,6 @@ The plugin classes have pre-defined hooks that are called during various stages
|
||||
- [Plugin Configuration](#plugin-configuration)
|
||||
- [Communicating with Factorio](#communicating-with-factorio)
|
||||
- [Defining Link Messages](#defining-link-messages)
|
||||
- [Defining Events](#defining-events)
|
||||
- [Defining Requests](#defining-requests)
|
||||
- [Sending Link Messages](#sending-link-messages)
|
||||
- [Handling connection events](#handling-connection-events)
|
||||
- [Collecting Statistics](#collecting-statistics)
|
||||
@@ -41,6 +39,7 @@ The usual guides for creating such packages apply.
|
||||
At minimum the `package.json` file must contain a version entry.
|
||||
|
||||
A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`.
|
||||
After creating a directory for your plugin, the boilerplate can be generated automaticlly from a set of tempaltes using `npm exec @clusterio/create -y -- --plugin-template` after which you will be asked which areas your plugin will interact with and your plugin's name.
|
||||
To add it to `plugin-list.json` so that it gets loaded use the `plugin add <path>` sub-command to either clusteriocontroller, clusteriohost or clusterioctl.
|
||||
Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on Windows).
|
||||
|
||||
@@ -302,6 +301,20 @@ local clusterio_api = require("modules/clusterio/api")
|
||||
clusterio_api.send_json("my_plugin_foo", { data = 123 })
|
||||
```
|
||||
|
||||
For convience a helper function is exposed on server specifically for handling IPC events.
|
||||
|
||||
```js
|
||||
async init() {
|
||||
this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this));
|
||||
}
|
||||
|
||||
async handleFoo(content) {
|
||||
// Do stuff with content
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This only handles IPC events, all other events must use `on`. You should not include the `ipc-` prefix when using this method. Error logging is handled for you, if you want custom error handling use `on`.
|
||||
|
||||
It's recommended to either use the plugin name as the channel name or to prefix the channel name with the name of the plugin if you need multiple channels.
|
||||
It's also important to catch any errors that might occur as they will otherwise be propogated to the instance code and kill the server.
|
||||
|
||||
@@ -325,175 +338,137 @@ Events are simple one-way notifications that invoke a handler on the target it's
|
||||
Requests are pairs of request and response messages where the request is sent to the target and the response is the reply back from the target.
|
||||
The requests are similar to HTTP requests, except both parties of a link may initiate one.
|
||||
|
||||
|
||||
### Defining Events
|
||||
|
||||
Events are defined as properties of the messages object exported on `plugin` that map to instances of the `Event` class from `lib/link`.
|
||||
The name of the property correspond to the handler invoked on the plugin class.
|
||||
The Event constructor takes an object of properties that define the event, for example the following could be defined in `plugin`:
|
||||
|
||||
```js
|
||||
messages: {
|
||||
startFrobnication: new lib.Event({
|
||||
type: "foo_frobber:start_frobnication",
|
||||
links: ["controller-host", "host-instance"],
|
||||
forwardTo: "instance",
|
||||
eventRequired: ["frobnication_type"],
|
||||
eventProperties: {
|
||||
"frobnication_type": { type: "string" },
|
||||
"urgent": { type: "boolean" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
```
|
||||
|
||||
This specifies an event that can be sent from the controller to a host, and from a host to an instance.
|
||||
It also specifies that the event must contain the property `frobnication_type`, with a string value in the data payload and that it may optionally contain a boolean `urgent` property.
|
||||
It will also be forwarded by hosts to a specific instance.
|
||||
|
||||
The following properties are recognized by the Event constructor:
|
||||
|
||||
#### type
|
||||
|
||||
The message type sent over the wire.
|
||||
This must start with the name of the plugin followed by colon and and be unique for the plugin.
|
||||
The type of the message sent over the socket will have the suffix `_event` appended to it.
|
||||
|
||||
#### links
|
||||
|
||||
An array of strings describing which links this event can be sent over.
|
||||
Direction matters, `"controller-host"` means the event can be sent from the controller to the host, but can't be sent back the other way, unless `"host-controller"` is also present in the links array.
|
||||
|
||||
The available endpoints are `controller`, `host`, `instance`, and `control`.
|
||||
Controller talks with host and control, and host talks to instance.
|
||||
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from controller to instance it must have both `"controller-host"` and `"host-instance"` in the links array).
|
||||
See `forwardTo` and `broadcastTo` for ways to forward an event to the next link in a chain.
|
||||
|
||||
#### forwardTo
|
||||
|
||||
Target to forward an event to.
|
||||
Can either be `"controller"`, to indicate a host should forward it to the controller, or `"instance"`, to indicate it should be forwarded to the instances specified by the `instance_id` event property.
|
||||
This works by using a default handler for the event at the links that forward it.
|
||||
|
||||
#### broadcastTo
|
||||
|
||||
Target to broadcast this message towards.
|
||||
A value of "instance" means the event will be broadcast to all instances downstream of the target it's sent to, but not back from where it came from.
|
||||
Currently, only "instance" is supported.
|
||||
This means that sending the event to a host from an instance will cause it to be broadcast to all instances of that host except for the instance it came from.
|
||||
|
||||
#### eventRequired
|
||||
|
||||
By default all properties defined in `eventProperties` are required to be present in the payload for the event.
|
||||
This can be overriden by specifying an array of properties which are required here.
|
||||
This is equivalent to using the `required` keyword in JSON schema.
|
||||
|
||||
#### eventProperties
|
||||
|
||||
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the event.
|
||||
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
|
||||
See [this guide][guide] for an introduction to writing JSON schemas.
|
||||
|
||||
The forwardTo and broadcastTo can be combined such that specifying `"controller"` as the forwardTo value and `"instance"` as the broadcastTo value will cause the event to be broadcast to all instances in the cluster.
|
||||
For this to work, you will need to specify `instance-host`, `host-controller`, `controller-host`, and `host-instance` as the links.
|
||||
|
||||
Keep in mind when forwarding events that if the target an event is being forwarded to is not online, the event will be dropped.
|
||||
Use a request if you need a confirmation that the message was received.
|
||||
|
||||
### Definining Requests
|
||||
|
||||
Requests are defined as properties of the messages object exported by `plugin` that map to instances of the `Request` class from `lib/link`.
|
||||
The name of the property corresponds to the handler invoked on the plugin class.
|
||||
The Request constructor takes an object of properties that define the event.
|
||||
Messages are defined as items of the `plugin.messages` array exported by `index.js`.
|
||||
For example, the following could be defined in `plugin`:
|
||||
|
||||
```js
|
||||
messages: {
|
||||
reportFrobnication: new lib.Request({
|
||||
type: "foo_frobber:report_frobnication",
|
||||
links: ["controller-host", "host-instance"],
|
||||
forwardTo: "instance",
|
||||
requestRequired: ["verbosity"],
|
||||
requestProperties: {
|
||||
"verbosity": { type: "integer" },
|
||||
"special": { type: "boolean" },
|
||||
},
|
||||
responseProperties: {
|
||||
"report": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
messages: [
|
||||
class Frobnicate {
|
||||
static plugin = "foo_frobber"; // Plugin name
|
||||
static type = "request";
|
||||
static src = "controller";
|
||||
static dst = "instance";
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
"verbosity": { type: "integer" },
|
||||
"special": { type: "boolean" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
required: ["verbosity"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
constructor(json) {
|
||||
this.verbosity = json.verbosity;
|
||||
this.special = json.special;
|
||||
}
|
||||
static fromJSON(json) {
|
||||
return new Frobnicate(json);
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
verbosity: this.verbosity,
|
||||
special: this.special,
|
||||
};
|
||||
}
|
||||
static Response = {
|
||||
jsonSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
"report": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
fromJSON(json) {
|
||||
return json
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
This specifies a request that can be sent from the controller to a host, and from a host to an instance.
|
||||
The request data must contain the property `verbosity` with an integer number as the value, as well as the `instance_id` property (implied by `forwardTo: "instance"`) and it may also contain a boolean `special` property.
|
||||
It also defines that the response sent must contain a `report` property mapping to an array of strings.
|
||||
When received by a host, it will also be forwarded to the instance specified by `instance_id`.
|
||||
This specifies a request that can be sent from the controller to an instance, being routed through the correct host behind the scenes.
|
||||
The request data must contain the property `verbosity` with an integer number as the value, and it may also contain a boolean `special` property.
|
||||
It also defines that the response sent may contain a `report` property mapping to an array of strings.
|
||||
|
||||
The following properties are recognized by the Request constructor:
|
||||
The following properties are recognized as part of a message class:
|
||||
|
||||
#### type
|
||||
#### static plugin
|
||||
|
||||
The message type sent over the wire.
|
||||
This must start with the name of the plugin followed by colon and and be unique for the plugin.
|
||||
The type of the message sent over the socket will have the suffix `_request` appended to it for the request and `_response` appended to it for the response.
|
||||
Must contain the name of the plugin this message is defined by.
|
||||
This property is not used in core messages.
|
||||
|
||||
#### links
|
||||
#### static type
|
||||
|
||||
An array of strings describing which links this request can be sent over.
|
||||
Direction matters; `"controller-host"` means the request can be sent from the controller to the host and the host can reply to the controller, but the host can't send a request to the controller unless `"host-controller"` is also present in the links array.
|
||||
A string describing the message type sent over the wire.
|
||||
This can be either `"request"` or `"event"`.
|
||||
The primary difference is whether they allow for a response.
|
||||
|
||||
The available endpoints are `controller`, `host`, `instance`, and `control`.
|
||||
Controller talks with host and control, and host talks to instance.
|
||||
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from controller to instance it must have both `"controller-host"` and `"host-instance"` in the links array).
|
||||
See `forwardTo` for ways to forward a request to the next link in a chain.
|
||||
#### static src
|
||||
|
||||
#### forwardTo
|
||||
A string or an array of strings describing where this message is allowed to originate from.
|
||||
The available endpoints are `"controller"`, `"host"`, `"instance"`, or `"control"`.
|
||||
Messages sent from a different source will be rejected.
|
||||
|
||||
Target to forward the request to.
|
||||
Can either be `"controller"` to indicate a host should forward it to the controller when receiving it from an instance, or `"instance"` to indicate it should be forwarded to the instances specified by the `instance_id` request property.
|
||||
This works by using a default handler for the request by the links that forward it.
|
||||
#### static dst
|
||||
|
||||
#### requestRequired
|
||||
A string or array of strings describing where this message may be addressed to.
|
||||
The available endpoints are `"controller"`, `"host"`, `"instance"`, or `"control"`.
|
||||
If the target is multiple hops away it the message will be forwarded towards its destination by the intermediaries.
|
||||
For example a message sent from a control connection an instance will be forwarded by the controller to the relevant host and from the host to the correct instance.
|
||||
Messages sent to a different destination will be rejected.
|
||||
|
||||
By default all properties defined in `requestProperties` are required to be present in the payload for the request.
|
||||
This can be overriden by specifying an array of properties which are required here.
|
||||
This is equivalent to using the `required` keyword in JSON schema.
|
||||
#### static jsonSchema
|
||||
|
||||
#### requestProperties
|
||||
A JSON schema that specifies what's valid to send in the message.
|
||||
|
||||
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the request.
|
||||
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
|
||||
See [this guide][guide] for an introduction to writing JSON schemas
|
||||
#### toJSON
|
||||
|
||||
#### responseRequired
|
||||
Optional method to serialize an instance of the class to a JSON object, see the [documentation for `JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description) for the semantics of this method.
|
||||
This should return an object that's valid according to `jsonSchema` and accepted by `fromJSON`, and may be omitted if the default serialisation by `JSON.stringify()` already does this.
|
||||
|
||||
Same as the requestRequired only for the response sent back by the target.
|
||||
#### static fromJSON
|
||||
|
||||
#### responseProperties
|
||||
Required function to restore the class from a JSON object.
|
||||
This function is called when the message reaches its destination and is expected to return an identical instance of the class.
|
||||
I.e. the result of `fromJSON(JSON.parse(JSON.stringify(instance)))` should be a complete copy of the message.
|
||||
|
||||
Same as the requestProperties only for the response sent back by the target.
|
||||
#### static Response
|
||||
|
||||
Optional [JSON sterilisable class](/docs/devs/json-serialisable-classes.md) that specifies the response to the request.
|
||||
Required properties are `jsonSchema` and `fromJSON`.
|
||||
|
||||
## Sending Link Messages
|
||||
|
||||
Link messages are sent by calling the `.send()` method on the Event/Request instance with the link you want to send it over and the data to send.
|
||||
For `InstancePlugin` code the link to the host is the `instance` itself, which is accessible through the `.instance` property of the `InstancePlugin`.
|
||||
The `.info` property of the plugin class exposes the data exported from the plugin's `plugin` export.
|
||||
In other words:
|
||||
Link messages are sent by calling the `.sendTo()` method on a connection object with the destination and the data you want to send, where the data is an instance of one of the defined message classes in plugin.messages.
|
||||
For example:
|
||||
|
||||
```js
|
||||
// In an InstancePlugin class
|
||||
async frobnicate() {
|
||||
this.info.messages.exampleEvent.send(this.instance, { foo: "bar" });
|
||||
const response = await this.instance.sendTo("controller", new messages.Frobnicate({
|
||||
verbosity: 2,
|
||||
special: false,
|
||||
}));
|
||||
console.log(response); // { report: [...] }
|
||||
}
|
||||
```
|
||||
|
||||
For the Request class the send method is async and returns the response data received from the target it was sent to, or throws an error if the request failed.
|
||||
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
|
||||
The destination specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
|
||||
|
||||
Example destinations:
|
||||
|
||||
```js
|
||||
sendTo("controller", message);
|
||||
sendTo("allHosts", message);
|
||||
sendTo("allControls", message);
|
||||
sendTo("allInstances", message);
|
||||
sendTo({ hostId: 123 }, message);
|
||||
sendTo({ instanceId: 123 }, message);
|
||||
sendTo({ controlId: 123 }, message);
|
||||
```
|
||||
|
||||
### Handling connection events
|
||||
|
||||
@@ -508,7 +483,7 @@ For example the sending of an event from an `InstancePlugin` class can be stoppe
|
||||
|
||||
```js
|
||||
if (this.host.connected) {
|
||||
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
|
||||
this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
}
|
||||
```
|
||||
|
||||
@@ -617,4 +592,4 @@ class CtlPlugin extends BaseCtlPlugin {
|
||||
module.exports = {
|
||||
CtlPlugin,
|
||||
}
|
||||
```
|
||||
```
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
"test": "pnpm run build && mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\"",
|
||||
"test": "pnpm run build && mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
|
||||
"fast-test": "FAST_TEST=y pnpm test",
|
||||
"cover": "nyc pnpm test",
|
||||
"fast-cover": "FAST_TEST=y nyc pnpm test",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
@@ -19,11 +19,12 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.7.4",
|
||||
"express": "^4.18.2",
|
||||
"finalhandler": "^1.2.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
@@ -35,11 +36,12 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@swc/core": "^1.4.0",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/finalhandler": "^1.2.0",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/node": "^20.4.5",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BlockList, type AddressInfo, isIPv4, isIPv6 } from "net";
|
||||
import type { ControllerArgs } from "../controller";
|
||||
import express, { type Request, type Response, type NextFunction, type Application } from "express";
|
||||
import type { Static } from "@sinclair/typebox";
|
||||
import finalhandler from "finalhandler";
|
||||
|
||||
import compression from "compression";
|
||||
import events, { EventEmitter } from "events";
|
||||
@@ -286,6 +287,26 @@ export default class Controller {
|
||||
// Load plugins
|
||||
await this.loadPlugins();
|
||||
|
||||
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
||||
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
||||
logger.log({
|
||||
level: "error",
|
||||
message: "Error while handling HTTP" +
|
||||
`${req.httpVersion} ${req.method} ${req.originalUrl}:\n${err.stack}`,
|
||||
meta: {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
httpVersion: req.httpVersion,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
},
|
||||
});
|
||||
|
||||
// Create and call the final handler, this is the same method used by express
|
||||
// This is used instead of next(err) to stop express from logging the error to the console
|
||||
finalhandler(req, res, { env: this.app.get("env") })(err);
|
||||
});
|
||||
|
||||
this.wsServer = new WsServer(this);
|
||||
|
||||
// Only start listening for connections after all plugins have loaded
|
||||
@@ -721,14 +742,29 @@ export default class Controller {
|
||||
|
||||
static addAppRoutes(app: Application, pluginInfos: any[]) {
|
||||
app.use((req: Request, res: Response, next) => {
|
||||
let startNs = process.hrtime.bigint();
|
||||
const startNs = process.hrtime.bigint();
|
||||
stream.finished(res, () => {
|
||||
let routePath = "static";
|
||||
if (req.route && req.route.path) {
|
||||
routePath = req.route.path;
|
||||
}
|
||||
let endNs = process.hrtime.bigint();
|
||||
endpointDurationSummary.labels(routePath).observe(Number(endNs - startNs) / 1e9);
|
||||
const endNs = process.hrtime.bigint();
|
||||
const durationMs = (Number(endNs - startNs) / 1e6);
|
||||
endpointDurationSummary.labels(routePath).observe(durationMs / 1e3);
|
||||
logger.log({
|
||||
level: "http",
|
||||
message: `HTTP${req.httpVersion} ${req.method} ${req.originalUrl}` +
|
||||
` (Status ${res.statusCode} in ${durationMs}ms)`,
|
||||
meta: {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
responseTime: durationMs,
|
||||
httpVersion: req.httpVersion,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
},
|
||||
});
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -284,7 +284,9 @@ ${err.stack}`
|
||||
);
|
||||
}
|
||||
|
||||
connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req));
|
||||
// Handle close before HostConnection does. This avoids a host
|
||||
// connection event happening between the connection breaking and
|
||||
// it being removed from the host connections list.
|
||||
connector.on("close", () => {
|
||||
if (this.hostConnections.get(data.id) === connection) {
|
||||
this.hostConnections.delete(data.id);
|
||||
@@ -292,6 +294,8 @@ ${err.stack}`
|
||||
logger.warn("Unlisted HostConnection closed");
|
||||
}
|
||||
});
|
||||
|
||||
connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req));
|
||||
this.hostConnections.set(data.id, connection);
|
||||
let src = new lib.Address(lib.Address.host, data.id);
|
||||
connector.ready(socket, src, sessionToken, undefined);
|
||||
@@ -331,10 +335,10 @@ ${err.stack}`
|
||||
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.control, id));
|
||||
|
||||
logger.verbose(`WsServer | registered control ${id} from ${this.remoteAddr(req)}`);
|
||||
let connection = new ControlConnection(data, connector, this.controller, user, id);
|
||||
connector.on("close", () => {
|
||||
this.controlConnections.delete(id);
|
||||
});
|
||||
let connection = new ControlConnection(data, connector, this.controller, user, id);
|
||||
this.controlConnections.set(id, connection);
|
||||
let account = new lib.AccountDetails(
|
||||
user.name,
|
||||
|
||||
@@ -12,10 +12,23 @@ const util = require("util");
|
||||
const yargs = require("yargs");
|
||||
|
||||
const { levels, logger, setLogLevel } = require("./logging");
|
||||
const { copyPluginTemplates } = require("./template");
|
||||
let dev = false;
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
|
||||
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
|
||||
// One option is to not call rl.close: SBoudrias/Inquirer.js#767
|
||||
// But this comes with its own issues: SBoudrias/Inquirer.js#899
|
||||
// The solution below was adapted from aws-amplify/amplify-cli#12347
|
||||
if (process.platform === "win32") {
|
||||
const _inquirer_close = inquirer.ui.Prompt.prototype.close;
|
||||
inquirer.ui.Prompt.prototype.close = function() {
|
||||
this.rl.terminal = false;
|
||||
_inquirer_close.call(this);
|
||||
};
|
||||
}
|
||||
|
||||
const availablePlugins = [
|
||||
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
||||
@@ -95,7 +108,7 @@ async function safeOutputFile(file, data, options={}) {
|
||||
async function execFile(cmd, args) {
|
||||
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
|
||||
let child = child_process.execFile(cmd, args, { shell: true }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
@@ -505,6 +518,7 @@ WantedBy=multi-user.target
|
||||
async function inquirerMissingArgs(args) {
|
||||
let answers = {};
|
||||
if (args.mode) { answers.mode = args.mode; }
|
||||
if (args["plugin-template"]) { answers.mode = "plugin-template"; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
@@ -517,6 +531,7 @@ async function inquirerMissingArgs(args) {
|
||||
{ name: "Host only", value: "host" },
|
||||
{ name: "Ctl only", value: "ctl" },
|
||||
{ name: "Plugins only", value: "plugins" },
|
||||
{ name: "Plugin template", value: "plugin-template" },
|
||||
],
|
||||
},
|
||||
], answers);
|
||||
@@ -644,6 +659,38 @@ async function inquirerMissingArgs(args) {
|
||||
}
|
||||
}
|
||||
|
||||
if (answers.mode === "plugin-template") {
|
||||
if (args["plugin-template"] && args["plugin-template"].length > 0) {
|
||||
answers.pluginTemplate = args["plugin-template"];
|
||||
}
|
||||
if (args["plugin-name"]) {
|
||||
answers.pluginName = args["plugin-name"];
|
||||
}
|
||||
answers.plugins = [];
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "pluginTemplate",
|
||||
message: "Plugin templates to use",
|
||||
choices: [
|
||||
{ name: "Controller", value: "controller" },
|
||||
{ name: "Host", value: "host" },
|
||||
{ name: "Instance", value: "instance" },
|
||||
{ name: "Lua Module", value: "module" },
|
||||
{ name: "Command Line", value: "ctl" },
|
||||
{ name: "Web UI", value: "web" },
|
||||
{ name: "No Typescript", value: "js" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
name: "pluginName",
|
||||
message: "Name of your new plugin",
|
||||
default: path.basename(path.resolve()),
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (dev) {
|
||||
answers.plugins = [];
|
||||
} else if (args.plugins) {
|
||||
@@ -720,7 +767,7 @@ async function main() {
|
||||
})
|
||||
.option("mode", {
|
||||
nargs: 1, describe: "Operating mode to install",
|
||||
choices: ["standalone", "controller", "host", "ctl", "plugins"],
|
||||
choices: ["standalone", "controller", "host", "ctl", "plugins", "plugin-template"],
|
||||
})
|
||||
.option("migrate-rename", {
|
||||
nargs: 0, describe: "Migrate from before slave/master rename", default: false, type: "boolean",
|
||||
@@ -746,6 +793,12 @@ async function main() {
|
||||
.option("plugins", {
|
||||
array: true, describe: "Plugins to install", type: "string",
|
||||
})
|
||||
.option("plugin-template", {
|
||||
array: true, describe: "Plugin template to use [plugin-template]", type: "string",
|
||||
})
|
||||
.option("plugin-name", {
|
||||
nargs: 1, describe: "Name of your new plugin [plugin-template]", type: "string",
|
||||
})
|
||||
.strict()
|
||||
;
|
||||
|
||||
@@ -779,6 +832,13 @@ async function main() {
|
||||
}
|
||||
|
||||
let answers = await inquirerMissingArgs(args);
|
||||
logger.verbose(JSON.stringify(answers));
|
||||
|
||||
if (answers.pluginTemplate) {
|
||||
await copyPluginTemplates(answers.pluginName, answers.pluginTemplate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (answers.downloadHeadless) {
|
||||
if (answers.factorioDir !== "factorio") {
|
||||
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
|
||||
@@ -786,8 +846,6 @@ async function main() {
|
||||
await downloadLinuxServer();
|
||||
}
|
||||
|
||||
logger.verbose(JSON.stringify(answers));
|
||||
|
||||
if (!dev) {
|
||||
await installClusterio(answers.mode, answers.plugins);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"use strict";
|
||||
const { logger } = require("./logging");
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
async function copyTemplateFile(src, dst, properties) {
|
||||
logger.verbose(`Writing ${dst} from template ${src}`);
|
||||
|
||||
// Closure, get the property value or throw
|
||||
function getProperty(property) {
|
||||
if (!properties.hasOwnProperty(property)) {
|
||||
throw new Error(`Unknown property (${property}) found in template file ${src}`);
|
||||
}
|
||||
|
||||
return properties[property];
|
||||
}
|
||||
|
||||
// Closure, Get the boolean conjunction of a set of properties
|
||||
function propertyConjunction(props) {
|
||||
for (const property of props) {
|
||||
if (property.startsWith("!")) {
|
||||
if (Boolean(getProperty(property.slice(1)))) {
|
||||
return false;
|
||||
}
|
||||
} else if (!Boolean(getProperty(property))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process the the template file
|
||||
const badChars = /[\(\)-+/*<>=]/;
|
||||
const templateData = await fs.readFile(src, "utf8");
|
||||
const outputData = templateData
|
||||
// match preprocessor comments: //%//
|
||||
.replace(/^\/\/%\/\/.*\n/gm, "")
|
||||
// match preprocessor insert: __property__
|
||||
.replace(/__([a-zA-Z][a-zA-Z0-9_]*?[a-zA-Z0-9]?)__/gm, (_, property) => getProperty(property))
|
||||
// match preprocessor conditionals: //%if <CONDITION> [// COMMENT]\n <CONTENT> \n//%endif [COMMENT]\n
|
||||
.replace(/^\/\/%if ([^\n\/]*)(?:\/\/.*?)?\n((?:.|\n)*?)\/\/%endif.*\n/gm, (_, condition, content) => {
|
||||
let include = false;
|
||||
const conjunctions = condition.split("|");
|
||||
for (const conjunct of conjunctions) {
|
||||
if (badChars.test(conjunct)) {
|
||||
throw new Error("Preprocessor condition can only contain logical operators" +
|
||||
"such as & | or ! it can not have arithmetic, comparisons or parenthesis"
|
||||
);
|
||||
}
|
||||
|
||||
const property_names = conjunct.split("&").map(s => s.trim());
|
||||
if (propertyConjunction(property_names)) {
|
||||
include = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return include ? content : "";
|
||||
});
|
||||
|
||||
// Attempt to write the output file, warn if it already exists
|
||||
try {
|
||||
await fs.outputFile(dst, outputData, { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
logger.warn(`Could not create file ${dst} because it already exists`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPluginTemplates(pluginName, templates) {
|
||||
logger.info(`Please wait, coping templates for ${templates.join(", ")}`);
|
||||
const files = new Map();
|
||||
const prepare = [];
|
||||
|
||||
const javascriptOnly = templates.includes("js");
|
||||
const webpack = templates.includes("controller") || templates.includes("web");
|
||||
|
||||
// Get the file extension and path to the templates
|
||||
const ext = javascriptOnly ? "js" : "ts";
|
||||
const templatePath = path.resolve(__dirname, javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts");
|
||||
const commonPath = path.resolve(__dirname, "./templates/common");
|
||||
|
||||
// Files included in all templates
|
||||
files.set(".gitignore", path.join(commonPath, ".gitignore"));
|
||||
files.set(".npmignore", path.join(commonPath, ".npmignore"));
|
||||
files.set("package.json", path.join(commonPath, "package.json"));
|
||||
files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
|
||||
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
|
||||
|
||||
// Files and dependencies to support typescript
|
||||
if (!javascriptOnly) {
|
||||
prepare.push("tsc --build");
|
||||
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
|
||||
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
|
||||
if (webpack) {
|
||||
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
|
||||
}
|
||||
}
|
||||
|
||||
// Files and dependences to support webpack
|
||||
if (webpack) {
|
||||
prepare.push("webpack-cli --env production");
|
||||
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath,
|
||||
templates.includes("web") ? `web/plugin.${ext}x` : `web/no_plugin.${ext}x`
|
||||
));
|
||||
}
|
||||
|
||||
// Files for the controller
|
||||
if (templates.includes("controller")) {
|
||||
files.set(`controller.${ext}`, path.join(templatePath, `controller.${ext}`));
|
||||
}
|
||||
|
||||
// Files for hosts
|
||||
if (templates.includes("host")) {
|
||||
files.set(`host.${ext}`, path.join(templatePath, `host.${ext}`));
|
||||
}
|
||||
|
||||
// Files for instances
|
||||
if (templates.includes("instance")) {
|
||||
files.set(`instance.${ext}`, path.join(templatePath, `instance.${ext}`));
|
||||
}
|
||||
|
||||
// Files for lua modules
|
||||
if (templates.includes("module")) {
|
||||
files.set(".luacheckrc", path.join(commonPath, ".luacheckrc"));
|
||||
files.set("module/module.json", path.join(commonPath, "module/module.json"));
|
||||
files.set("module/control.lua", path.join(commonPath, "module/control.lua"));
|
||||
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
|
||||
if (templates.includes("instance")) {
|
||||
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
|
||||
}
|
||||
}
|
||||
|
||||
// Files for the control tool
|
||||
if (templates.includes("ctl")) {
|
||||
files.set(`ctl.${ext}`, path.join(templatePath, `ctl.${ext}`));
|
||||
}
|
||||
|
||||
// Properties that will control the replacements in the templates
|
||||
const properties = {
|
||||
typescript: !javascriptOnly,
|
||||
controller: templates.includes("controller"),
|
||||
host: templates.includes("host"),
|
||||
instance: templates.includes("instance"),
|
||||
module: templates.includes("module"),
|
||||
ctl: templates.includes("ctl"),
|
||||
web: templates.includes("web"),
|
||||
plugin_name: pluginName,
|
||||
prepare: prepare.join(" && "),
|
||||
ext: ext,
|
||||
};
|
||||
|
||||
// Write all the template files
|
||||
const writes = [];
|
||||
for (let [dst, src] of files) {
|
||||
writes.push(copyTemplateFile(src, dst, properties));
|
||||
}
|
||||
|
||||
// Wait for writes to complete
|
||||
await Promise.all(writes);
|
||||
logger.info("Successfully wrote all template files");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
copyPluginTemplates,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
webpack.config.js
|
||||
dist/web/build
|
||||
dist/browser
|
||||
@@ -0,0 +1,54 @@
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
|
||||
--- Top level module table, contains event handlers and public methods
|
||||
local MyModule = {
|
||||
events = {},
|
||||
on_nth_tick = {},
|
||||
}
|
||||
|
||||
--- global is 'synced' between players, you should use your plugin name to avoid conflicts
|
||||
-- setupGlobalData should either be removed or called during clusterio_api.events.on_server_startup
|
||||
local globalData = {}
|
||||
local function setupGlobalData()
|
||||
if global["__plugin_name__"] == nil then
|
||||
global["__plugin_name__"] = {
|
||||
-- starting values go here
|
||||
}
|
||||
end
|
||||
globalData = global["__plugin_name__"]
|
||||
end
|
||||
|
||||
--- Public methods should be available though your top level module table
|
||||
function MyModule.foo()
|
||||
game.print("foo")
|
||||
end
|
||||
|
||||
--- Private methods should be local to the file, this will prevent others from calling it
|
||||
local function bar()
|
||||
game.print("bar")
|
||||
end
|
||||
|
||||
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_load
|
||||
MyModule.events[clusterio_api.events.on_server_startup] = function(event)
|
||||
setupGlobalData()
|
||||
game.print(game.table_to_json(event))
|
||||
end
|
||||
|
||||
--- Factorio events are accessible through defines.events, you can have one handler per event per module
|
||||
MyModule.events[defines.events.on_player_crafted_item] = function(event)
|
||||
game.print(game.table_to_json(event))
|
||||
//%if instance
|
||||
clusterio_api.send_json("__plugin_name__-plugin_example_ipc", {
|
||||
tick = game.tick, player_name = game.get_player(event.player_index).name
|
||||
})
|
||||
//%endif
|
||||
end
|
||||
|
||||
--- Nth tick is a special case that requires its own table, the index represents the time period between calls in ticks
|
||||
MyModule.on_nth_tick[300] = function()
|
||||
game.print(game.tick)
|
||||
bar()
|
||||
end
|
||||
|
||||
--- Always return the top level module table from control, this is how clusterio will access your event handlers
|
||||
return MyModule
|
||||
@@ -0,0 +1,3 @@
|
||||
--- Expose any globals here, typically just your control file if you want to access it through rcon or commands
|
||||
-- luacheck: globals __plugin_name__
|
||||
__plugin_name__ = require("modules/__plugin_name__/control")
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "__plugin_name__",
|
||||
"load": [
|
||||
"control.lua"
|
||||
],
|
||||
"require": [
|
||||
//%if instance
|
||||
"globals.lua"
|
||||
//%endif
|
||||
],
|
||||
"dependencies": {
|
||||
"clusterio": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
|
||||
-- Access the exports from other modules using require("modules/__plugin_name__")
|
||||
return require("modules/__plugin_name__/control")
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "__plugin_name__",
|
||||
"version": "0.1.0",
|
||||
"description": "Example Description. Package. Change me in package.json",
|
||||
//%if typescript
|
||||
"main": "dist/node/index.js",
|
||||
//%endif
|
||||
//%if !typescript
|
||||
"main": "index.js",
|
||||
//%endif
|
||||
"scripts": {
|
||||
"prepare": "__prepare__"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
//%// Typescript and type declarations
|
||||
//%if typescript
|
||||
"typescript": "^5.1.6",
|
||||
"@types/node": "^20.4.5",
|
||||
//%endif
|
||||
//%if typescript & web
|
||||
"@types/react": "^18.2.21",
|
||||
//%endif
|
||||
//%// Everything else
|
||||
//%if web
|
||||
"antd": "^5.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
//%endif
|
||||
//%if web | controller // The controller uses webpack even when there is no plugin code
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
//%endif
|
||||
"@clusterio/lib": "^2.0.0-alpha.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.__ext__x",
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "__plugin_name__",
|
||||
library: { type: "window", name: "plugin___plugin_name__" },
|
||||
exposes: {
|
||||
"./": "./index.__ext__",
|
||||
"./package.json": "./package.json",
|
||||
//%if web
|
||||
"./web": "./web/index.__ext__x",
|
||||
//%endif
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
//%if web
|
||||
"antd": { import: false },
|
||||
"react": { import: false },
|
||||
"react-dom": { import: false },
|
||||
//%endif
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
|
||||
const {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
} = require("./messages");
|
||||
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
exampleDatabase;
|
||||
storageDirty = false;
|
||||
|
||||
//%endif
|
||||
async init() {
|
||||
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
//%endif
|
||||
}
|
||||
|
||||
async onControllerConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
|
||||
}
|
||||
//%if instance
|
||||
|
||||
async onInstanceConfigFieldChanged(instance, field, curr, prev) {
|
||||
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onSaveData() {
|
||||
this.logger.info("controller::onSaveData");
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("controller::onShutdown");
|
||||
}
|
||||
|
||||
async onPlayerEvent(instance, event) {
|
||||
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
}
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request) {
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return values.length ? new ExampleSubscribableUpdate(values) : null;
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
const { BaseCtlPlugin } = require("@clusterio/ctl");
|
||||
const { CommandTree, Command } = require("@clusterio/lib");
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const pluginCommands = new CommandTree({
|
||||
name: "__plugin_name__", description: "The default description I forgot to change",
|
||||
});
|
||||
|
||||
pluginCommands.add(new Command({
|
||||
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
|
||||
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
console.log(response);
|
||||
console.log(args);
|
||||
},
|
||||
}));
|
||||
|
||||
/* eslint-enable no-console */
|
||||
class CtlPlugin extends BaseCtlPlugin {
|
||||
async addCommands(rootCommand) {
|
||||
rootCommand.add(pluginCommands);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CtlPlugin,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseHostPlugin } = require("@clusterio/host");
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
|
||||
class HostPlugin extends BaseHostPlugin {
|
||||
async init() {
|
||||
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
|
||||
async onHostConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("host::onShutdown");
|
||||
}
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HostPlugin,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const Messages = require("./messages");
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.js",
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.js",
|
||||
});
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.js",
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.js",
|
||||
});
|
||||
//%endif
|
||||
|
||||
const plugin = {
|
||||
name: "__plugin_name__",
|
||||
title: "__plugin_name__",
|
||||
description: "Example Description. Plugin. Change me in index.js",
|
||||
//%if controller
|
||||
|
||||
controllerEntrypoint: "./controller",
|
||||
controllerConfigFields: {
|
||||
"__plugin_name__.myControllerField": {
|
||||
title: "My Controller Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if host
|
||||
|
||||
hostEntrypoint: "./host",
|
||||
hostConfigFields: {
|
||||
"__plugin_name__.myHostField": {
|
||||
title: "My Host Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if instance
|
||||
|
||||
instanceEntrypoint: "./instance",
|
||||
instanceConfigFields: {
|
||||
"__plugin_name__.myInstanceField": {
|
||||
title: "My Instance Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if ctl
|
||||
|
||||
ctlEntrypoint: "./ctl",
|
||||
controlConfigFields: {
|
||||
"__plugin_name__.myControlField": {
|
||||
title: "My Control Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
|
||||
messages: [
|
||||
Messages.PluginExampleEvent,
|
||||
Messages.PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
Messages.ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
],
|
||||
//%if web // Web content template has an example route which is the plugin name
|
||||
|
||||
webEntrypoint: "./web",
|
||||
routes: [
|
||||
"/__plugin_name__",
|
||||
],
|
||||
//%endif
|
||||
//%if controller & !web // The controller always includes web entry even if there is no content
|
||||
|
||||
webEntrypoint: "./web",
|
||||
routes: [],
|
||||
//%endif
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseInstancePlugin } = require("@clusterio/host");
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
|
||||
class InstancePlugin extends BaseInstancePlugin {
|
||||
async init() {
|
||||
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%if module
|
||||
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
|
||||
//%endif
|
||||
}
|
||||
|
||||
async onInstanceConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
async onPlayerEvent(event) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
}
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
//%if module
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
//%endif
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%if module
|
||||
|
||||
async handlePluginExampleIPC(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InstancePlugin,
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
"use strict";
|
||||
const { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } = require("@clusterio/lib");
|
||||
const { Type } = require("@sinclair/typebox");
|
||||
|
||||
class PluginExampleEvent {
|
||||
static type = "event";
|
||||
static src = ["host", "control"];
|
||||
static dst = ["controller", "host", "instance"];
|
||||
static plugin = "__plugin_name__";
|
||||
static permission = "__plugin_name__.example.permission.event";
|
||||
|
||||
constructor(myString, myNumberArray) {
|
||||
this.myString = myString;
|
||||
this.myNumberArray = myNumberArray;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"myString": Type.String(),
|
||||
"myNumberArray": Type.Array(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json) {
|
||||
return new PluginExampleEvent(json.myString, json.myNumberArray);
|
||||
}
|
||||
}
|
||||
|
||||
class PluginExampleRequest {
|
||||
static type = "request";
|
||||
static src = ["host", "control"];
|
||||
static dst = ["controller", "host", "instance"];
|
||||
static plugin = "__plugin_name__";
|
||||
static permission = "__plugin_name__.example.permission.request";
|
||||
|
||||
constructor(myString, myNumberArray) {
|
||||
this.myString = myString;
|
||||
this.myNumberArray = myNumberArray;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"myString": Type.String(),
|
||||
"myNumberArray": Type.Array(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json) {
|
||||
return new PluginExampleRequest(json.myString, json.myNumberArray);
|
||||
}
|
||||
|
||||
static Response = plainJson(Type.Object({
|
||||
"myResponseString": Type.String(),
|
||||
"myResponseNumbers": Type.Array(Type.Number()),
|
||||
}));
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
class ExampleSubscribableValue {
|
||||
constructor(id, updatedAtMs, isDeleted) {
|
||||
this.id = id;
|
||||
this.updatedAtMs = updatedAtMs;
|
||||
this.isDeleted = isDeleted;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
updatedAtMs: Type.Number(),
|
||||
isDeleted: Type.Boolean(),
|
||||
});
|
||||
|
||||
static fromJSON(json) {
|
||||
return new this(json.id, json.updatedAtMs, json.isDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
class ExampleSubscribableUpdate {
|
||||
static type = "event";
|
||||
static src = "controller";
|
||||
static dst = "control";
|
||||
static plugin = "__plugin_name__";
|
||||
static permission = "__plugin_name__.example.permission.subscribe";
|
||||
|
||||
constructor(updates) {
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"updates": Type.Array(ExampleSubscribableValue.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json) {
|
||||
return new this(json.updates.map(update => ExampleSubscribableValue.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
//%endif
|
||||
|
||||
module.exports = {
|
||||
PluginExampleEvent,
|
||||
PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableValue,
|
||||
ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
};
|
||||
Whitespace-only changes.
@@ -0,0 +1,78 @@
|
||||
import React, {
|
||||
useContext, useEffect, useState,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
useCallback, useSyncExternalStore,
|
||||
//%endif
|
||||
} from "react";
|
||||
|
||||
// import {
|
||||
//
|
||||
// } from "antd";
|
||||
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
} from "../messages";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
function MyTemplatePage() {
|
||||
let control = useContext(ControlContext);
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
const plugin = control.plugins.get("__plugin_name__");
|
||||
const [subscribableData, synced] = plugin.useSubscribableData();
|
||||
//%endif
|
||||
|
||||
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
|
||||
<h2>__plugin_name__</h2>
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
|
||||
//%endif
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
|
||||
|
||||
//%endif
|
||||
async init() {
|
||||
this.pages = [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
permission: "__plugin_name__.page.view",
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
];
|
||||
|
||||
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
} from "./messages";
|
||||
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
exampleDatabase!: Map<string, ExampleSubscribableValue>;
|
||||
storageDirty = false;
|
||||
|
||||
//%endif
|
||||
async init() {
|
||||
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
//%endif
|
||||
}
|
||||
|
||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
|
||||
}
|
||||
//%if instance
|
||||
|
||||
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onSaveData() {
|
||||
this.logger.info("controller::onSaveData");
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("controller::onShutdown");
|
||||
}
|
||||
|
||||
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
|
||||
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
}
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request: lib.SubscriptionRequest) {
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return values.length ? new ExampleSubscribableUpdate(values) : null;
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
type Control = any; // import type { Control } from "@clusterio/ctl";
|
||||
import { BaseCtlPlugin } from "@clusterio/ctl";
|
||||
import { CommandTree, Command } from "@clusterio/lib";
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const pluginCommands = new CommandTree({
|
||||
name: "__plugin_name__", description: "The default description I forgot to change",
|
||||
});
|
||||
|
||||
pluginCommands.add(new Command({
|
||||
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
|
||||
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { foo: string }, control: Control) {
|
||||
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
console.log(response);
|
||||
console.log(args);
|
||||
},
|
||||
}));
|
||||
|
||||
/* eslint-enable no-console */
|
||||
export class CtlPlugin extends BaseCtlPlugin {
|
||||
async addCommands(rootCommand: CommandTree) {
|
||||
rootCommand.add(pluginCommands);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseHostPlugin } from "@clusterio/host";
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
|
||||
export class HostPlugin extends BaseHostPlugin {
|
||||
async init() {
|
||||
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
|
||||
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("host::onShutdown");
|
||||
}
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as Messages from "./messages";
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.ts",
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.ts",
|
||||
});
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.ts",
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.ts",
|
||||
});
|
||||
//%endif
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
//%if controller
|
||||
export interface ControllerConfigFields {
|
||||
"__plugin_name__.myControllerField": string;
|
||||
}
|
||||
//%endif
|
||||
//%if host
|
||||
export interface HostConfigFields {
|
||||
"__plugin_name__.myHostField": string;
|
||||
}
|
||||
//%endif
|
||||
//%if instance
|
||||
export interface InstanceConfigFields {
|
||||
"__plugin_name__.myInstanceField": string;
|
||||
}
|
||||
//%endif
|
||||
//%if ctl
|
||||
export interface ControlConfigFields {
|
||||
"__plugin_name__.myControlField": string;
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "__plugin_name__",
|
||||
title: "__plugin_name__",
|
||||
description: "Example Description. Plugin. Change me in index.ts",
|
||||
//%if controller
|
||||
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
controllerConfigFields: {
|
||||
"__plugin_name__.myControllerField": {
|
||||
title: "My Controller Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if host
|
||||
|
||||
hostEntrypoint: "./dist/node/host",
|
||||
hostConfigFields: {
|
||||
"__plugin_name__.myHostField": {
|
||||
title: "My Host Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if instance
|
||||
|
||||
instanceEntrypoint: "./dist/node/instance",
|
||||
instanceConfigFields: {
|
||||
"__plugin_name__.myInstanceField": {
|
||||
title: "My Instance Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if ctl
|
||||
|
||||
ctlEntrypoint: "./dist/node/ctl",
|
||||
controlConfigFields: {
|
||||
"__plugin_name__.myControlField": {
|
||||
title: "My Control Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
|
||||
messages: [
|
||||
Messages.PluginExampleEvent,
|
||||
Messages.PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
Messages.ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
],
|
||||
//%if web // Web content template has an example route which is the plugin name
|
||||
|
||||
webEntrypoint: "./web",
|
||||
routes: [
|
||||
"/__plugin_name__",
|
||||
],
|
||||
//%endif
|
||||
//%if controller & !web // The controller always includes web entry even if there is no content
|
||||
|
||||
webEntrypoint: "./web",
|
||||
routes: [],
|
||||
//%endif
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
//%if module
|
||||
|
||||
type PuginExampleIPC = {
|
||||
tick: number,
|
||||
player_name: string,
|
||||
};
|
||||
//%endif
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
async init() {
|
||||
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%if module
|
||||
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
|
||||
//%endif
|
||||
}
|
||||
|
||||
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
async onPlayerEvent(event: lib.PlayerEvent) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
//%endif
|
||||
}
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%if module
|
||||
|
||||
async handlePluginExampleIPC(event: PuginExampleIPC) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
export class PluginExampleEvent {
|
||||
declare ["constructor"]: typeof PluginExampleEvent;
|
||||
static type = "event" as const;
|
||||
static src = ["host", "control"] as const;
|
||||
static dst = ["controller", "host", "instance"] as const;
|
||||
static plugin = "__plugin_name__" as const;
|
||||
static permission = "__plugin_name__.example.permission.event";
|
||||
|
||||
constructor(
|
||||
public myString: string,
|
||||
public myNumberArray: number[],
|
||||
) {
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"myString": Type.String(),
|
||||
"myNumberArray": Type.Array(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof PluginExampleEvent.jsonSchema>) {
|
||||
return new PluginExampleEvent(json.myString, json.myNumberArray);
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginExampleRequest {
|
||||
declare ["constructor"]: typeof PluginExampleRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["host", "control"] as const;
|
||||
static dst = ["controller", "host", "instance"] as const;
|
||||
static plugin = "__plugin_name__" as const;
|
||||
static permission = "__plugin_name__.example.permission.request";
|
||||
|
||||
constructor(
|
||||
public myString: string,
|
||||
public myNumberArray: number[],
|
||||
) {
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"myString": Type.String(),
|
||||
"myNumberArray": Type.Array(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof PluginExampleRequest.jsonSchema>) {
|
||||
return new PluginExampleRequest(json.myString, json.myNumberArray);
|
||||
}
|
||||
|
||||
static Response = plainJson(Type.Object({
|
||||
"myResponseString": Type.String(),
|
||||
"myResponseNumbers": Type.Array(Type.Number()),
|
||||
}));
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
export class ExampleSubscribableValue {
|
||||
constructor(
|
||||
public id: string,
|
||||
public updatedAtMs: number,
|
||||
public isDeleted: boolean,
|
||||
) {
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
updatedAtMs: Type.Number(),
|
||||
isDeleted: Type.Boolean(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id, json.updatedAtMs, json.isDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExampleSubscribableUpdate {
|
||||
declare ["constructor"]: typeof ExampleSubscribableUpdate;
|
||||
static type = "event" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "control" as const;
|
||||
static plugin = "__plugin_name__" as const;
|
||||
static permission = "__plugin_name__.example.permission.subscribe";
|
||||
|
||||
constructor(
|
||||
public updates: ExampleSubscribableValue[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"updates": Type.Array(ExampleSubscribableValue.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.updates.map(update => ExampleSubscribableValue.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
//%endif
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"references": [
|
||||
{ "path": "../../packages/lib/tsconfig.browser.json" },
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
//%if web
|
||||
{ "path": "./tsconfig.browser.json" },
|
||||
//%endif
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,85 @@
|
||||
import React, {
|
||||
useContext, useEffect, useState,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
useCallback, useSyncExternalStore,
|
||||
//%endif
|
||||
} from "react";
|
||||
|
||||
// import {
|
||||
//
|
||||
// } from "antd";
|
||||
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
} from "../messages";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
function MyTemplatePage() {
|
||||
const control = useContext(ControlContext);
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
|
||||
const [subscribableData, synced] = plugin.useSubscribableData();
|
||||
//%endif
|
||||
|
||||
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
|
||||
<h2>__plugin_name__</h2>
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
|
||||
//%endif
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
|
||||
|
||||
//%endif
|
||||
async init() {
|
||||
this.pages = [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller | !web
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
];
|
||||
|
||||
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# Templates
|
||||
|
||||
Create has the ability to create new plugins from templates to avoid needing to manually copy lots of boiler plate. This folder contains all those templates for both Javascript and Typescript plugins. These templates have custom preprocessor instructions that can be used to alter a template based on developer input, this syntax is described below.
|
||||
|
||||
- [The Basics](#the-basics)
|
||||
- [Insert Instruction](#insert-instruction) (`__variable__`)
|
||||
- [Conditional Instruction](#conditional-instruction) (`//%if` `//%endif`)
|
||||
- [Available Variables](#available-variables)
|
||||
|
||||
## The Basics
|
||||
|
||||
All files can contain preprocessor instructions whether json, js, lua, or anything else. All instructions use the same syntax although it has been designed to work well with js and ts files.
|
||||
|
||||
Except for the insert instruction, all preprocessors use a `//%` prefix and must be on their own line. These lines are never included in the output. Parsing is stopped when `//` is encountered to allow for comments on preprocessors. Eg. `//%if foo // this is a comment`.
|
||||
|
||||
Empty instructions, aka just comments, can be inserted into templates using `//%//` and are not included in the output file.
|
||||
|
||||
`templates/plugin-js` contains all the files which will be used by a javascript only plugin.
|
||||
`templates/plugin-ts` contains all the files which will be used by a plugin that supports typescript.
|
||||
`templates/common` contains files used by both types of plugins, this is mostly lua code or root files like `package.json`
|
||||
|
||||
Files are not copied via wildcards and instead are explicitly listed within `template.js` so any new templates will need to be added there.
|
||||
|
||||
## Insert Instruction
|
||||
|
||||
There are a number of template variables available to be inserted into templates. All variables are raw string values, that is they are inserted directly into the output file without any modifications. To signify a place to insert a variable you need only surround its identifier with double underscores, examples below.
|
||||
|
||||
Example Variables:
|
||||
|
||||
```sh
|
||||
my_number=1
|
||||
my_string=foo
|
||||
my_property=bar: "baz"
|
||||
```
|
||||
|
||||
Example Template:
|
||||
|
||||
```ts
|
||||
const __my_string__: number = __my_number__;
|
||||
console.log(__my_string__);
|
||||
|
||||
console.log({
|
||||
foo: "Hello, __my_string__!",
|
||||
__my_property__,
|
||||
my_property: __my_string__,
|
||||
});
|
||||
```
|
||||
|
||||
Example Output:
|
||||
|
||||
```ts
|
||||
const foo: number = 1;
|
||||
console.log(foo);
|
||||
|
||||
console.log({
|
||||
foo: "Hello, foo!",
|
||||
bar: "baz",
|
||||
my_property: foo,
|
||||
});
|
||||
```
|
||||
|
||||
## Conditional Instruction
|
||||
|
||||
Conditionals allow for code to be included/omitted based on the value of template variables. Currently only boolean logic in [disjunctive normal form](https://en.wikipedia.org/wiki/Disjunctive_normal_form) is supported and any variables that are not booleans are evaluated with the [Boolean constructor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean).
|
||||
|
||||
Variables used in condition statements do not require double underscores. Their values can be inverted using `!`. Arithmetic and comparison operations are not supported. Parentheses are also not supported, conditions must be in DNF.
|
||||
|
||||
Conversion to DNF:
|
||||
`A & (B | !C)` -> `A & B | A & !C` evaluated as `(A and B) or (A and not C)`
|
||||
|
||||
Example Variables:
|
||||
|
||||
```sh
|
||||
my_string=foo
|
||||
say_hello=true
|
||||
say_done=true
|
||||
verbose=false
|
||||
```
|
||||
|
||||
Example Template:
|
||||
|
||||
```ts
|
||||
//%if verbose
|
||||
console.log("Starting...");
|
||||
//%endif
|
||||
//%if say_hello & my_string
|
||||
console.log("Hello, __my_string__!");
|
||||
//%endif
|
||||
//%if say_done | verbose
|
||||
console.log("Done.");
|
||||
//%endif
|
||||
```
|
||||
|
||||
Example Output:
|
||||
|
||||
```ts
|
||||
console.log("Hello, foo!");
|
||||
console.log("Done.");
|
||||
```
|
||||
|
||||
## Available Variables
|
||||
|
||||
| Identifier | Example | Description |
|
||||
| - | - | - |
|
||||
| typescript | `true` / `false` | `true` if typescript templates are included |
|
||||
| controller | `true` / `false` | `true` if controller templates are included |
|
||||
| host | `true` / `false` | `true` if host templates are included |
|
||||
| instance | `true` / `false` | `true` if instance templates are included |
|
||||
| module | `true` / `false` | `true` if module templates are included |
|
||||
| ctl | `true` / `false` | `true` if control templates are included |
|
||||
| web | `true` / `false` | `true` if web templates are included |
|
||||
| plugin_name | `my_plugin` | name of the plugin |
|
||||
| prepare | `tsc --build` | commands to run for the prepare script |
|
||||
| ext | `ts` / `js` | file extension of source files |
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"as-table": "^1.0.55",
|
||||
"fs-extra": "^11.1.1",
|
||||
"phin": "^3.7.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio host server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"fs-extra": "^11.1.1",
|
||||
"jimp": "^0.22.8",
|
||||
|
||||
@@ -77,9 +77,16 @@ export class SaveModule {
|
||||
|
||||
static async fromSave(json: Static<typeof SaveModule.jsonSchema>, root: JSZip) {
|
||||
const module = new this(lib.ModuleInfo.fromJSON(json));
|
||||
module.files = new Map(
|
||||
await Promise.all(json.files.map(async f => [f, await root.file(f)!.async("nodebuffer")] as const))
|
||||
);
|
||||
module.files = new Map(await Promise.all(json.files
|
||||
.map(filename => ({filename, file: root.file(filename)}))
|
||||
.filter(({filename, file}) => {
|
||||
if (file === null) {
|
||||
lib.logger.warn(`Missing file ${filename} in save`);
|
||||
}
|
||||
return file !== null;
|
||||
})
|
||||
.map(async ({filename, file}) => [filename, await file!.async("nodebuffer")] as const)
|
||||
));
|
||||
return module;
|
||||
}
|
||||
|
||||
@@ -395,8 +402,11 @@ export async function patch(savePath: string, modules: SaveModule[]) {
|
||||
}
|
||||
} else {
|
||||
for (let module of patchInfo.modules) {
|
||||
for (let file of module.files.keys()) {
|
||||
zip.remove(root.file(file)!.name);
|
||||
for (let filepath of module.files.keys()) {
|
||||
const file = root.file(filepath);
|
||||
if (file !== null) {
|
||||
zip.remove(file.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
patchInfo.modules = [];
|
||||
|
||||
@@ -562,6 +562,13 @@ export class FactorioServer extends events.EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO at the moment this is just a helper function, it has the potential for schema checking
|
||||
handle<Event>(eventName: string, handler: (event: Event) => Promise<void>) {
|
||||
this.on(`ipc-${eventName}`, (event) => handler(event).catch(err => {
|
||||
this._logger.error(`Error handling ipc event:\n${err.stack}`);
|
||||
}));
|
||||
}
|
||||
|
||||
async _handleIpc(line: Buffer) {
|
||||
let channelEnd = line.indexOf("?");
|
||||
if (channelEnd === -1) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { logger } from "./logging";
|
||||
import { ModInfo } from "./data";
|
||||
import TypedEventEmitter from "./TypedEventEmitter";
|
||||
import { safeOutputFile } from "./file_ops";
|
||||
|
||||
export interface ModStoreEvents {
|
||||
/** A stored mod was created, updated or deleted */
|
||||
@@ -74,6 +76,21 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
}
|
||||
|
||||
static async fromDirectory(modsDirectory: string) {
|
||||
type ModInfoCache = Static<typeof ModInfo.jsonSchema>[];
|
||||
const cacheFilename = path.join(modsDirectory, "mod-info-cache.json");
|
||||
let cache = new Map<string, ModInfo>();
|
||||
try {
|
||||
const content = await fs.readFile(cacheFilename, "utf8");
|
||||
cache = new Map((JSON.parse(content) as ModInfoCache).map(item => {
|
||||
const mod = ModInfo.fromJSON(item);
|
||||
return [mod.filename, mod];
|
||||
}));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
logger.warn(`Error loading ${cacheFilename}:\n${err.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
const files = new Map<string, ModInfo>();
|
||||
for (let entry of await fs.readdir(modsDirectory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
@@ -81,10 +98,23 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.name.toLowerCase().endsWith(".zip")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.toLowerCase().endsWith(".tmp.zip")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cachedModInfo = cache.get(entry.name);
|
||||
if (cachedModInfo) {
|
||||
const stat = await fs.stat(path.join(modsDirectory, entry.name));
|
||||
if (cachedModInfo.mtimeMs === stat.mtimeMs) {
|
||||
files.set(cachedModInfo.filename, cachedModInfo);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Loading info for Mod ${entry.name}`);
|
||||
let modInfo: ModInfo;
|
||||
try {
|
||||
@@ -96,6 +126,12 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
files.set(modInfo.filename, modInfo);
|
||||
}
|
||||
|
||||
if (files.size) {
|
||||
logger.info(`Loaded info for ${files.size} mods`);
|
||||
const content: ModInfoCache = [...files.values()].map(mod => mod.toJSON());
|
||||
await safeOutputFile(cacheFilename, JSON.stringify(content));
|
||||
}
|
||||
|
||||
return new this(modsDirectory, files);
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,11 @@ export default class ModInfo {
|
||||
*/
|
||||
size = 0;
|
||||
|
||||
/**
|
||||
* Last modification time of the file.
|
||||
*/
|
||||
mtimeMs = 0;
|
||||
|
||||
/**
|
||||
* SHA1 hash of this mod
|
||||
*/
|
||||
@@ -147,6 +152,7 @@ export default class ModInfo {
|
||||
static jsonSchema = Type.Object({
|
||||
...this.infoJsonSchema.properties,
|
||||
"size": Type.Optional(Type.Integer()),
|
||||
"mtime_ms": Type.Optional(Type.Number()),
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
"updated_at_ms": Type.Optional(Type.Number()),
|
||||
"is_deleted": Type.Optional(Type.Boolean()),
|
||||
@@ -170,6 +176,7 @@ export default class ModInfo {
|
||||
|
||||
// Additional data
|
||||
if (json.size) { modInfo.size = json.size; }
|
||||
if (json.mtime_ms) { modInfo.mtimeMs = json.mtime_ms; }
|
||||
if (json.sha1) { modInfo.sha1 = json.sha1; }
|
||||
if (json.updated_at_ms) { modInfo.updatedAtMs = json.updated_at_ms; }
|
||||
if (json.is_deleted) { modInfo.isDeleted = json.is_deleted; }
|
||||
@@ -192,6 +199,7 @@ export default class ModInfo {
|
||||
json.dependencies = this.dependencies;
|
||||
}
|
||||
if (this.size) { json.size = this.size; }
|
||||
if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; }
|
||||
if (this.sha1) { json.sha1 = this.sha1; }
|
||||
if (this.updatedAtMs) { json.updated_at_ms = this.updatedAtMs; }
|
||||
if (this.isDeleted) { json.is_deleted = this.isDeleted; }
|
||||
@@ -229,6 +237,7 @@ export default class ModInfo {
|
||||
|
||||
size: stat.size,
|
||||
sha1: await libHash.hashFile(modPath),
|
||||
mtime_ms: stat.mtimeMs,
|
||||
updated_at_ms: stat.mtimeMs,
|
||||
is_deleted: false,
|
||||
});
|
||||
|
||||
@@ -334,10 +334,11 @@ export default class ModPack {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in missing settings with their default values
|
||||
* Fill in missing and incorrect settings with their default values
|
||||
*
|
||||
* Uses the provided setting prototypes to add any missing mod settings
|
||||
* in the mod pack with the default value from the prototype.
|
||||
* Uses the provided setting prototypes to correct any missing and or
|
||||
* incorrect mod settings in the mod pack with the default value from
|
||||
* the prototype.
|
||||
*
|
||||
* @param settingPrototypes -
|
||||
* Setting prototypes exported from the game.
|
||||
@@ -360,13 +361,23 @@ export default class ModPack {
|
||||
logger.warn(`Ignoring ${prototype.name} with missing default_value`);
|
||||
continue;
|
||||
}
|
||||
if (this.settings[settingType].get(prototype.name)) {
|
||||
continue;
|
||||
let value = this.settings[settingType].get(prototype.name)?.value ?? prototype.default_value;
|
||||
const type = prototype.type;
|
||||
if (
|
||||
type === "bool-setting" && typeof value !== "boolean"
|
||||
|| type === "color-setting" && typeof value !== "object"
|
||||
|| (type === "int-setting" || type === "double-setting") && typeof value !== "number"
|
||||
) {
|
||||
value = prototype.default_value;
|
||||
}
|
||||
|
||||
let value = prototype.default_value;
|
||||
if (prototype.type === "color-setting") {
|
||||
value = libLuaTools.normalizeColor(value);
|
||||
if (type === "string-setting" && typeof value !== "string") {
|
||||
value = typeof value === "object" ? prototype.default_value : String(value ?? "");
|
||||
}
|
||||
if (type === "color-setting") {
|
||||
if (typeof value !== "object") {
|
||||
value = prototype.default_value;
|
||||
}
|
||||
value = libLuaTools.normalizeColor(value as object);
|
||||
}
|
||||
this.settings[settingType].set(prototype.name, { value });
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@ export class Link {
|
||||
) {
|
||||
this.handle(libData.PingRequest, async () => {});
|
||||
|
||||
// Prevent warnings about possible memory leak due to large number of event listeners
|
||||
connector.setMaxListeners(20);
|
||||
|
||||
// Process messages received by the connector
|
||||
connector.on("message", payload => {
|
||||
try {
|
||||
|
||||
@@ -8,8 +8,9 @@ export const levels = Object.freeze({
|
||||
warn: 2,
|
||||
audit: 3,
|
||||
info: 4,
|
||||
server: 5,
|
||||
verbose: 6,
|
||||
http: 5,
|
||||
server: 6,
|
||||
verbose: 7,
|
||||
});
|
||||
|
||||
export const colors = {
|
||||
@@ -18,6 +19,7 @@ export const colors = {
|
||||
warn: "bold brightYellow",
|
||||
audit: "bold brightGreen",
|
||||
info: "bold brightBlue",
|
||||
http: "bold",
|
||||
server: "bold",
|
||||
verbose: "bold grey",
|
||||
} as const;
|
||||
@@ -163,7 +165,7 @@ export const logger = winston.createLogger({
|
||||
// Who in their right mind thought that log levels should be hard coded
|
||||
// into the type when you can define custom levels you want to support?
|
||||
export type Logger = Omit<winston.Logger,
|
||||
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "http" | "verbose" | "input" | "silly" |
|
||||
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly" |
|
||||
"emerg" | "alert" | "crit" | "warning" | "notice" |
|
||||
"child"
|
||||
> & {
|
||||
@@ -172,6 +174,7 @@ export type Logger = Omit<winston.Logger,
|
||||
warn: winston.LeveledLogMethod,
|
||||
audit: winston.LeveledLogMethod,
|
||||
info: winston.LeveledLogMethod,
|
||||
http: winston.LeveledLogMethod,
|
||||
server: winston.LeveledLogMethod,
|
||||
verbose: winston.LeveledLogMethod,
|
||||
child(options: object): Logger,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"browser": "src/index.ts",
|
||||
"main": "dist/browser/src/index.js",
|
||||
"scripts": {
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.1.4",
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@swc/core": "^1.4.0",
|
||||
"antd": "^5.13.0",
|
||||
"assert": "^2.0.0",
|
||||
|
||||
@@ -424,7 +424,15 @@ function InputColor(props: {
|
||||
value: lib.ModSettingColor,
|
||||
onChange: (value: lib.ModSettingColor) => void,
|
||||
}) {
|
||||
const value = props.value;
|
||||
// In certain edge cases it's possible a value for this mod setting does not exist
|
||||
// or is of the wrong type. Fallback to black if this is the case.
|
||||
function isColor(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return false;
|
||||
}
|
||||
return "r" in input && "g" in input && "b" in input && "a" in input;
|
||||
}
|
||||
const value = isColor(props.value) ? props.value : { r: 0, g: 0, b: 0, a: 1 };
|
||||
return <ColorPicker
|
||||
defaultFormat="rgb"
|
||||
defaultValue={`rgba(${value.r * 255}, ${value.g * 255}, ${value.b * 255}, ${value.a}`}
|
||||
@@ -774,7 +782,7 @@ export default function ModPackViewPage() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (modifiedModPack!.settings[scope].get(settingName)!.value !== value) {
|
||||
if (modifiedModPack!.settings[scope].get(settingName)?.value !== value) {
|
||||
pushChange({ type: "settings.set", scope, name: settingName, value: { value }});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ function TransferModal(props: ModalProps) {
|
||||
autoFocus
|
||||
showSearch
|
||||
filterOption={(input, option) => (
|
||||
(option?.title.toLowerCase().indexOf(input.toLowerCase())??-1) >= 0
|
||||
(option?.title!.toLowerCase().indexOf(input.toLowerCase())??-1) >= 0
|
||||
)}
|
||||
>
|
||||
{[...instances.values()].filter(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-global_chat",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -21,8 +21,8 @@
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/react": "^18.2.21",
|
||||
"typescript": "^5.1.6",
|
||||
|
||||
@@ -190,7 +190,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
}))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
return new msg.DatabaseStatsRequest.Response(
|
||||
playerDatastore.map(x => x.length).reduce((a, b) => b - a, 0),
|
||||
playerDatastore.map(x => x.length).reduce((acc, val) => acc + val, 0),
|
||||
playerDatastore.length,
|
||||
{
|
||||
name: playerDatastore[0] && playerDatastore[0].name || "-",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-inventory_sync",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Danielv <danielv@danielv.no>",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
|
||||
@@ -25,8 +25,8 @@
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/react": "^18.2.21",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-player_auth",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"description": "Clusterio plugin authenticating logged in players to the web interface",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -26,8 +26,8 @@
|
||||
"jsonwebtoken": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/node": "^20.4.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-research_sync",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"description": "Clusterio plugin for synchronizing research between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -25,8 +25,8 @@
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/react": "^18.2.21",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-statistics_exporter",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"description": "Clusterio plugin for gathering statistics from Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -21,8 +21,8 @@
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/react": "^18.2.21",
|
||||
"typescript": "^5.1.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-subspace_storage",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"version": "2.0.0-alpha.18",
|
||||
"description": "Clusterio plugin for sharing storage between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -25,8 +25,8 @@
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.4.5",
|
||||
|
||||
@@ -1054,6 +1054,7 @@ describe("Integration of Clusterio", function() {
|
||||
"factorioVersion: 1.1\n" +
|
||||
"dependencies:\n" +
|
||||
`size: ${stat.size}\n` +
|
||||
`mtimeMs: ${stat.mtimeMs}\n` +
|
||||
`sha1: ${hash}\n` +
|
||||
`updatedAtMs: ${stat.mtimeMs}\n` +
|
||||
"isDeleted: false\n",
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("lib/data/ModInfo", function() {
|
||||
check(ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] }));
|
||||
check(ModInfo.fromJSON({ filename: "MyMod_1.0.0.zip" }));
|
||||
check(ModInfo.fromJSON({ size: 1024 }));
|
||||
check(ModInfo.fromJSON({ mtime_ms: new Date(2024, 0, 1).getTime() }));
|
||||
check(ModInfo.fromJSON({ sha1: "verified-as-MyMod" }));
|
||||
check(ModInfo.fromJSON({ is_deleted: true }));
|
||||
|
||||
@@ -49,6 +50,7 @@ describe("lib/data/ModInfo", function() {
|
||||
dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"],
|
||||
filename: "MyMod_1.0.0.zip",
|
||||
size: 1024,
|
||||
mtime_ms: new Date(2024, 0, 1).getTime(),
|
||||
sha1: "verified-as-MyMod",
|
||||
is_deleted: true,
|
||||
}));
|
||||
@@ -92,6 +94,7 @@ describe("lib/data/ModInfo", function() {
|
||||
dependencies: [],
|
||||
filename: "empty_mod_1.0.0.zip",
|
||||
size: stat.size,
|
||||
mtime_ms: stat.mtimeMs,
|
||||
updated_at_ms: stat.mtimeMs,
|
||||
sha1: hash,
|
||||
})
|
||||
|
||||
@@ -179,6 +179,84 @@ describe("lib/data/ModPack", function() {
|
||||
"runtime-per-user": {},
|
||||
});
|
||||
});
|
||||
it("should coerce values of the incorrect type into the correct type", function() {
|
||||
const boolDefault = true;
|
||||
const intDefault = 123;
|
||||
const doubleDefault = 1234.5;
|
||||
const stringDefault = "default";
|
||||
const colorDefault = { r: 1, g: 0, b: 1, a: 1};
|
||||
const settingDefaultValues = {
|
||||
"bool-setting": boolDefault,
|
||||
"int-setting": intDefault,
|
||||
"double-setting": doubleDefault,
|
||||
"string-setting": stringDefault,
|
||||
"color-setting": colorDefault,
|
||||
};
|
||||
const possibleValues = ["bool", "int", "double", "string", "color"];
|
||||
const settingPrototypes = {
|
||||
"bool-setting": {},
|
||||
"int-setting": {},
|
||||
"double-setting": {},
|
||||
"string-setting": {},
|
||||
"color-setting": {},
|
||||
};
|
||||
for (const [type, default_value] of Object.entries(settingDefaultValues)) {
|
||||
for (const value of possibleValues) {
|
||||
const settingName = `${type}-with-${value}-value`;
|
||||
settingPrototypes[type][settingName] = {
|
||||
type,
|
||||
name: settingName,
|
||||
setting_type: "startup",
|
||||
default_value,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const pack = new ModPack();
|
||||
const settingValues = {
|
||||
"bool": false,
|
||||
"int": 1,
|
||||
"double": 2.5,
|
||||
"string": "str",
|
||||
"color": { r: 0, g: 1, b: 0, a: 1 },
|
||||
};
|
||||
for (const type of Object.keys(settingDefaultValues)) {
|
||||
for (const [valueType, value] of Object.entries(settingValues)) {
|
||||
pack.settings.startup.set(`${type}-with-${valueType}-value`, { value });
|
||||
}
|
||||
}
|
||||
pack.fillDefaultSettings(settingPrototypes);
|
||||
assert.deepEqual(
|
||||
pack.settings["startup"],
|
||||
new Map([
|
||||
["bool-setting-with-bool-value", false],
|
||||
["bool-setting-with-int-value", boolDefault],
|
||||
["bool-setting-with-double-value", boolDefault],
|
||||
["bool-setting-with-string-value", boolDefault],
|
||||
["bool-setting-with-color-value", boolDefault],
|
||||
["int-setting-with-bool-value", intDefault],
|
||||
["int-setting-with-int-value", 1],
|
||||
["int-setting-with-double-value", 2.5],
|
||||
["int-setting-with-string-value", intDefault],
|
||||
["int-setting-with-color-value", intDefault],
|
||||
["double-setting-with-bool-value", doubleDefault],
|
||||
["double-setting-with-int-value", 1],
|
||||
["double-setting-with-double-value", 2.5],
|
||||
["double-setting-with-string-value", doubleDefault],
|
||||
["double-setting-with-color-value", doubleDefault],
|
||||
["string-setting-with-bool-value", "false"],
|
||||
["string-setting-with-int-value", "1"],
|
||||
["string-setting-with-double-value", "2.5"],
|
||||
["string-setting-with-string-value", "str"],
|
||||
["string-setting-with-color-value", stringDefault],
|
||||
["color-setting-with-bool-value", colorDefault],
|
||||
["color-setting-with-int-value", colorDefault],
|
||||
["color-setting-with-double-value", colorDefault],
|
||||
["color-setting-with-string-value", colorDefault],
|
||||
["color-setting-with-color-value", { r: 0, g: 1, b: 0, a: 1}],
|
||||
].map(([k, v]) => [k, { value: v }])),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".fromModPackString()", function() {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Manual Tests
|
||||
|
||||
This folder contains files used for performing manual tests that are non-trivial to automate.
|
||||
|
||||
## Setup test mod
|
||||
|
||||
Run `node packages/lib/dist/node/build_mod.js --source-dir test/manual/test_mod/ --output-dir mods/` to create the test mod.
|
||||
|
||||
## Test handling of bad setting values
|
||||
|
||||
1. Create a ModPack with missmatched values with `node test/manual/test_settings_mod_pack.js` and import it.
|
||||
2. Run a data export on an instance with the mod pack assigned, the values will get coerced to the correct types.
|
||||
3. Use ctl to set the settings back to their wrong values:
|
||||
|
||||
```bash
|
||||
SETTING_TYPES=(bool-setting int-setting double-setting string-setting color-setting)
|
||||
VALUE_TYPES=(bool int double string color missing)
|
||||
VALUES=(
|
||||
--bool-setting false
|
||||
--int-setting 1
|
||||
--double-setting 0.5
|
||||
--string-setting str
|
||||
--color-setting '{"r":0,"g":1,"b":0,"a":1}'
|
||||
--remove-setting
|
||||
)
|
||||
node packages/ctl mod-pack edit test-settings $(
|
||||
for TYPE in "${SETTING_TYPES[@]}" ; do
|
||||
for (( I = 0; I < ${#VALUE_TYPES[@]}; I++ )); do
|
||||
printf "%s startup %s-with-%s-value %s " ${VALUES[((I * 2))]} $TYPE ${VALUE_TYPES[I]} ${VALUES[((I * 2 + 1))]}
|
||||
done
|
||||
done
|
||||
)
|
||||
```
|
||||
|
||||
4. Verify that the Web UI gracefully handles all combinations of wrong settings types.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "test_mod",
|
||||
"version": "0.0.0",
|
||||
"title": "test_mod",
|
||||
"author": "",
|
||||
"factorio_version": "1.1"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
local settings = {
|
||||
["bool-setting"] = true,
|
||||
["int-setting"] = 123,
|
||||
["double-setting"] = 1234.5,
|
||||
["string-setting"] = "string",
|
||||
["color-setting"] = { r = 1, g = 0, b = 1, a = 1 },
|
||||
}
|
||||
|
||||
local possible_values = { "missing", "bool", "int", "double", "string", "color" }
|
||||
|
||||
for name, default_value in pairs(settings) do
|
||||
for _, value in ipairs(possible_values) do
|
||||
data:extend({
|
||||
{
|
||||
type = name,
|
||||
name = name .. "-with-" .. value .. "-value",
|
||||
setting_type = "startup",
|
||||
default_value = default_value,
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
const { ModPack } = require("@clusterio/lib");
|
||||
|
||||
const pack = new ModPack();
|
||||
pack.name = "test-settings";
|
||||
pack.mods.set("test_mod", { name: "test_mod", enabled: true, version: "0.0.0" });
|
||||
const settingTypes = [
|
||||
"bool-setting",
|
||||
"int-setting",
|
||||
"double-setting",
|
||||
"string-setting",
|
||||
"color-setting",
|
||||
];
|
||||
const settingValues = {
|
||||
"bool": false,
|
||||
"int": 1,
|
||||
"double": 0.5,
|
||||
"string": "str",
|
||||
"color": { r: 0, g: 1, b: 0, a: 1 },
|
||||
// "missing":
|
||||
};
|
||||
for (const type of settingTypes) {
|
||||
for (const [valueType, value] of Object.entries(settingValues)) {
|
||||
pack.settings.startup.set(`${type}-with-${valueType}-value`, { value });
|
||||
}
|
||||
}
|
||||
/* eslint-disable no-console */
|
||||
console.log(pack.toModPackString());
|
||||
Reference in new issue
Block a user