diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml
new file mode 100644
index 00000000..80d77eff
--- /dev/null
+++ b/.github/workflows/node.yml
@@ -0,0 +1,51 @@
+name: Node.js CI
+
+on:
+ push:
+ pull_request:
+
+env:
+ # The packages use workspace:^ and catalog: versions, so they only resolve
+ # inside a clusterio checkout with this repository under external_plugins
+ CLUSTERIO_REPOSITORY: clusterio/clusterio
+ CLUSTERIO_REF: master
+ # Node version to use for the tests. This is used by the setup-node action
+ NODE_VERSION: lts/*
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ defaults:
+ run:
+ working-directory: external_plugins/ExpCluster
+ steps:
+ - name: Checkout Clusterio
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ env.CLUSTERIO_REPOSITORY }}
+ ref: ${{ env.CLUSTERIO_REF }}
+ - name: Checkout ExpCluster
+ uses: actions/checkout@v4
+ with:
+ path: external_plugins/ExpCluster
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ - name: Install and build
+ working-directory: .
+ run: pnpm install --no-frozen-lockfile
+ - name: Run tests
+ run: pnpm --filter "@expcluster/*" run test
+ - name: Run reference checks
+ run: |
+ # pnpm install runs prepare in dependency order, which hides missing
+ # project references. Building from an empty dist one package at a time
+ # will fail unless every cross package import is referenced correctly.
+ for pkg in exp_*/; do
+ rm -rf exp_*/dist
+ echo "::group::${pkg%/}"
+ (cd "$pkg" && pnpm exec tsc --build)
+ echo "::endgroup::"
+ done
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9fa851c4..216a5196 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -9,7 +9,7 @@ For developers wanting to add features please follow these guidelines:
- Lua is checked with [emmylua](https://github.com/EmmyLuaLs/emmylua-analyzer-rust), configured by `.emmyrc.json`. Install the EmmyLua extension rather than sumnekolua / luals, and use factoriomod-debug to generate the factorio api types. Note that an inline cast must be written `--[[@as T]]`, the spaced form is not parsed.
- Changes should be made on your own fork and merged into `main` through a pull request.
- Each pull request should be limited to one feature or a few bug fixes and link to the related issue page.
-- Pull requests are automatically linted and documentation checked.
+- Pull requests are automatically linted, then built and tested against the latest clusterio `master`.
- Pull requests are manually reviewed to maintain code and language quality.
- New features should have the branch names: `feature/feature-name`
- Bug fixes should have the branch names: `fix/bug-name`
diff --git a/README.md b/README.md
index 82f48abd..3bc7ad44 100644
--- a/README.md
+++ b/README.md
@@ -42,13 +42,11 @@ This module is currently not published and therefore can not be installed via `n
See [Contributing](CONTRIBUTING.md) for how to make pull requests and issues.
-## Translation
-
-Contact for access.
-[translate]: https://translate.aperx.org
+Details of the scenario can also be seen on [Wiki](https://wiki.aperx.org/game/factorio/scenario).
-## Wiki
-[wiki]: https://wiki.aperx.org/game/factorio/scenario
+For the locale, access to [Translation](https://translate.aperx.org).
+
+
## Releases
diff --git a/exp_auto_restart/README.md b/exp_auto_restart/README.md
new file mode 100644
index 00000000..954b9b09
--- /dev/null
+++ b/exp_auto_restart/README.md
@@ -0,0 +1,26 @@
+# ExpGaming - Auto Restart
+
+Restarts hosts, and optionally the controller, that need a restart once nobody is playing on them.
+
+A host needs a restart after a plugin or clusterio update, and after a config change that only applies on restart. The web UI shows this as "Restart Required". This plugin waits until the host has had no players online for a while and then sends it the same restart request the web UI button does.
+
+Restarting a host stops every instance on it. Once the host is back, the instances that were running are started again. Instances with auto start enabled are started by the host itself.
+
+## Requirements
+
+- Hosts must be run with `--can-restart` so their process monitor starts them again. The systemd units and run scripts written by the installer do this.
+- `controller.system_metrics_interval` must be above 0. That poll is how the controller learns a host needs a restart.
+- Installing a plugin on a host does not yet mark it as needing a restart, only updates and config changes do. Until clusterio does, restart the host by hand after an install.
+
+## Config
+
+All fields are on the controller.
+
+| Field | Default | Description |
+|---|---|---|
+| `exp_auto_restart.idle_seconds` | 300 | How long a host must have had no players online before it is restarted. |
+| `exp_auto_restart.scope` | host | With `cluster`, players on any host hold back every restart. |
+| `exp_auto_restart.start_instances` | true | Start the instances that were running again once the host is back. |
+| `exp_auto_restart.restart_controller` | false | Also restart the controller when it needs it and no players are online anywhere. |
+
+Hosts are checked every 10 seconds. A host that refuses the restart, for example because it was not run with `--can-restart`, is logged once and left alone until its process changes.
diff --git a/exp_auto_restart/controller.ts b/exp_auto_restart/controller.ts
new file mode 100644
index 00000000..130dd2b0
--- /dev/null
+++ b/exp_auto_restart/controller.ts
@@ -0,0 +1,295 @@
+import type {
+ Controller, ControllerPluginContext, HostConnection, HostRecord, InstanceRecord,
+} from "@clusterio/controller";
+import * as lib from "@clusterio/lib";
+
+/** How often hosts are checked for a pending restart */
+export const CHECK_INTERVAL_MS = 10_000;
+/** How long to wait for a restarted host to report its instances back before giving up on starting them */
+export const PENDING_TIMEOUT_MS = 10 * 60_000;
+
+/** A host that was told to restart and has not reported all of its instances back yet */
+export interface PendingRestart {
+ requestedAtMs: number;
+ /** Instances that were running when the restart was requested */
+ instanceIds: Set;
+}
+
+export class AutoRestart {
+ controller: Controller;
+ logger: lib.Logger;
+ name: string;
+
+ /** When each host, and the cluster as a whole, last became empty of players. Absent while players are online. */
+ idleSince = new Map();
+ /** Hosts that were told to restart and are waiting for their instances to come back */
+ pending = new Map();
+ /** Process start time of each host last asked to restart, so the same process is not asked twice */
+ requested = new Map();
+ /** Hosts already warned about not being restartable */
+ warned = new Set();
+ controllerRestarting = false;
+ private checkInterval?: ReturnType;
+
+ constructor(context: ControllerPluginContext) {
+ this.controller = context.controller;
+ this.logger = context.logger;
+ this.name = context.plugin.name;
+ }
+
+ /** Attach to the controller hooks and start checking for restarts. */
+ start() {
+ const hooks = this.controller.hooks;
+ hooks.shutdown.attach(this.name, () => this.stop());
+ hooks.playerEvent.attach(this.name, (instance, event) => this.onPlayerEvent(instance, event));
+ hooks.instanceStatusChanged.attach(this.name, (instance, prev) => this.onInstanceStatusChanged(instance, prev));
+ hooks.hostConnectionEvent.attach(this.name, (connection, event) => this.onHostConnectionEvent(connection, event));
+
+ if (this.controller.config.get("controller.system_metrics_interval") <= 0) {
+ this.logger.warn(
+ "controller.system_metrics_interval is 0, so the controller never learns which hosts need a restart"
+ );
+ }
+ this.refreshIdle();
+ this.checkInterval = setInterval(() => {
+ this.check().catch((err: any) => {
+ this.logger.error(`Unexpected error checking for restarts:\n${err.stack ?? err.message}`);
+ });
+ }, CHECK_INTERVAL_MS);
+ this.checkInterval.unref();
+ }
+
+ stop() {
+ clearInterval(this.checkInterval);
+ }
+
+ onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
+ if (event.type === "join" || event.type === "leave") {
+ this.refreshIdle();
+ }
+ }
+
+ onInstanceStatusChanged(instance: InstanceRecord, prev?: lib.InstanceStatus) {
+ this.refreshIdle();
+ // A host reports every instance it has when it connects, which is how
+ // a restarted host announces it is back
+ if (prev === "unknown") {
+ this.instanceReported(instance);
+ }
+ }
+
+ onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") {
+ if (event === "connect" || event === "close") {
+ this.refreshIdle();
+ }
+ }
+
+ /** Players online per host, counted from the users online on running instances. */
+ onlinePlayers() {
+ const counts = new Map();
+ for (const user of this.controller.users.records.values()) {
+ if (user.isDeleted) {
+ continue;
+ }
+ for (const instanceId of user.instances) {
+ const instance = this.controller.instances.get(instanceId);
+ // Nobody is online on an instance that is not running, whatever
+ // the user records say after a host crashed
+ if (!instance || instance.status !== "running") {
+ continue;
+ }
+ const hostId = instance.config.get("instance.assigned_host");
+ if (hostId === null) {
+ continue;
+ }
+ counts.set(hostId, (counts.get(hostId) ?? 0) + 1);
+ }
+ }
+ return counts;
+ }
+
+ /** Record when connected hosts and the cluster became empty, from the players online now. */
+ refreshIdle(nowMs = Date.now()) {
+ const counts = this.onlinePlayers();
+ for (const host of this.controller.hosts.values()) {
+ if (host.isDeleted || !host.connected) {
+ this.idleSince.delete(host.id);
+ continue;
+ }
+ this.markIdle(host.id, !counts.get(host.id), nowMs);
+ }
+
+ let total = 0;
+ for (const count of counts.values()) {
+ total += count;
+ }
+ this.markIdle("cluster", total === 0, nowMs);
+ }
+
+ private markIdle(key: number | "cluster", idle: boolean, nowMs: number) {
+ if (!idle) {
+ this.idleSince.delete(key);
+ } else if (!this.idleSince.has(key)) {
+ this.idleSince.set(key, nowMs);
+ }
+ }
+
+ /** How long a host has been without players, or undefined while it has some. Uses the whole cluster with scope cluster. */
+ idleMs(hostId: number, nowMs: number) {
+ const scope = this.controller.config.get("exp_auto_restart.scope");
+ const since = this.idleSince.get(scope === "cluster" ? "cluster" : hostId);
+ return since === undefined ? undefined : nowMs - since;
+ }
+
+ /** Restart every host, and the controller if enabled, that needs it and has been empty for long enough. */
+ async check(nowMs = Date.now()) {
+ this.refreshIdle(nowMs);
+ this.expirePending(nowMs);
+ const waitMs = this.controller.config.get("exp_auto_restart.idle_seconds") * 1000;
+
+ for (const system of this.controller.systems.values()) {
+ if (system.id === "controller" || system.isDeleted || !system.restartRequired) {
+ continue;
+ }
+ const hostId = system.id;
+ const host = this.controller.hosts.get(hostId);
+ if (!host || host.isDeleted || !host.connected) {
+ continue;
+ }
+ // Already asked, either waiting for it to come back or it refused
+ if (this.pending.has(hostId) || this.requested.get(hostId) === system.processStartedAtMs) {
+ continue;
+ }
+ if (!system.canRestart) {
+ this.warnOnce(hostId, `Host ${host.name} needs a restart but is not run with --can-restart`);
+ continue;
+ }
+ const idleMs = this.idleMs(hostId, nowMs);
+ if (idleMs === undefined || idleMs < waitMs) {
+ continue;
+ }
+ await this.restartHost(host, system, idleMs, nowMs);
+ }
+
+ if (this.controller.config.get("exp_auto_restart.restart_controller")) {
+ await this.checkController(nowMs, waitMs);
+ }
+ }
+
+ async restartHost(host: Readonly, system: lib.SystemInfo, idleMs: number, nowMs: number) {
+ const running = [...this.controller.instances.values()].filter(
+ instance => instance.status === "running" && instance.config.get("instance.assigned_host") === host.id
+ );
+ this.requested.set(host.id, system.processStartedAtMs);
+ if (running.length && this.controller.config.get("exp_auto_restart.start_instances")) {
+ this.pending.set(host.id, {
+ requestedAtMs: nowMs,
+ instanceIds: new Set(running.map(instance => instance.id)),
+ });
+ }
+ this.logger.info(
+ `Restarting host ${host.name} after ${Math.round(idleMs / 1000)} s without players, ` +
+ `${running.length} instance(s) running`
+ );
+
+ try {
+ await this.controller.sendTo({ hostId: host.id }, new lib.HostRestartRequest());
+ } catch (err: any) {
+ this.pending.delete(host.id);
+ // The host refusing is final for this process, anything else is retried
+ if (!(err instanceof lib.RequestError)) {
+ this.requested.delete(host.id);
+ }
+ this.logger.error(`Failed to restart host ${host.name}: ${err.message}`);
+ }
+ }
+
+ /** An instance on a host that reconnected has reported its status. */
+ instanceReported(instance: InstanceRecord) {
+ const hostId = instance.config.get("instance.assigned_host");
+ if (hostId === null) {
+ return;
+ }
+ const pending = this.pending.get(hostId);
+ if (!pending?.instanceIds.delete(instance.id)) {
+ return;
+ }
+ if (!pending.instanceIds.size) {
+ this.pending.delete(hostId);
+ }
+
+ // The host starts these itself right after reporting them
+ if (instance.status !== "stopped" || instance.config.get("instance.auto_start")) {
+ return;
+ }
+ this.startInstance(instance);
+ }
+
+ async startInstance(instance: InstanceRecord) {
+ const name = instance.config.get("instance.name");
+ this.logger.info(`Starting ${name} again after its host restarted`);
+ try {
+ await this.controller.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
+ } catch (err: any) {
+ this.logger.error(`Failed to start ${name} after its host restarted: ${err.message}`);
+ }
+ }
+
+ /** Forget hosts that were restarted but never reported their instances back. */
+ expirePending(nowMs: number) {
+ for (const [hostId, pending] of this.pending) {
+ if (nowMs - pending.requestedAtMs >= PENDING_TIMEOUT_MS) {
+ this.pending.delete(hostId);
+ this.logger.warn(
+ `Host ${hostId} has not reported back since it was restarted, giving up on starting its instances`
+ );
+ }
+ }
+ }
+
+ async checkController(nowMs: number, waitMs: number) {
+ if (this.controllerRestarting) {
+ return;
+ }
+ const system = this.controller.systems.get("controller");
+ if (!system?.restartRequired) {
+ return;
+ }
+ if (!system.canRestart) {
+ this.warnOnce("controller", "The controller needs a restart but is not run with --can-restart");
+ return;
+ }
+ const since = this.idleSince.get("cluster");
+ if (since === undefined || nowMs - since < waitMs) {
+ return;
+ }
+ const downgrade = await this.controller.checkRestartDowngrade();
+ if (downgrade) {
+ this.warnOnce(
+ "controller",
+ `Not restarting the controller because installed version ${downgrade.installedVersion} ` +
+ `is older than running version ${downgrade.runningVersion}`
+ );
+ return;
+ }
+
+ this.controllerRestarting = true;
+ this.logger.info(`Restarting controller after ${Math.round((nowMs - since) / 1000)} s without players`);
+ this.controller.shouldRestart = true;
+ this.controller.stop().catch((err: any) => {
+ this.logger.error(`Failed to restart the controller: ${err.message}`);
+ });
+ }
+
+ private warnOnce(key: number | "controller", message: string) {
+ if (this.warned.has(key)) {
+ return;
+ }
+ this.warned.add(key);
+ this.logger.warn(message);
+ }
+}
+
+export default async function (context: ControllerPluginContext) {
+ new AutoRestart(context).start();
+}
diff --git a/exp_auto_restart/index.ts b/exp_auto_restart/index.ts
new file mode 100644
index 00000000..0d3274cc
--- /dev/null
+++ b/exp_auto_restart/index.ts
@@ -0,0 +1,52 @@
+import * as lib from "@clusterio/lib";
+
+declare module "@clusterio/lib" {
+ export interface ControllerConfigFields {
+ "exp_auto_restart.idle_seconds": number;
+ "exp_auto_restart.scope": "host" | "cluster";
+ "exp_auto_restart.start_instances": boolean;
+ "exp_auto_restart.restart_controller": boolean;
+ }
+}
+
+export const plugin: lib.PluginDeclaration = {
+ name: "exp_auto_restart",
+ title: "ExpGaming - Auto Restart",
+ description: "Clusterio plugin restarting hosts and the controller once no players are online",
+
+ controllerEntrypoint: "./dist/node/controller.js",
+ controllerConfigFields: {
+ "exp_auto_restart.idle_seconds": {
+ title: "Idle Seconds",
+ description: "How long a host must have had no players online before it is restarted",
+ type: "number",
+ initialValue: 300,
+ validator: (value: number) => {
+ if (value < 0) {
+ throw new Error("Idle seconds cannot be negative");
+ }
+ },
+ },
+ "exp_auto_restart.scope": {
+ title: "Scope",
+ description: "Whether players on other hosts also hold back a restart",
+ type: "string",
+ enum: ["host", "cluster"],
+ initialValue: "host",
+ },
+ "exp_auto_restart.start_instances": {
+ title: "Start Instances",
+ description: "Start the instances that were running again once the host is back",
+ type: "boolean",
+ initialValue: true,
+ },
+ "exp_auto_restart.restart_controller": {
+ title: "Restart Controller",
+ description: "Also restart the controller when it needs it and no players are online anywhere",
+ type: "boolean",
+ initialValue: false,
+ },
+ },
+
+ webEntrypoint: "./web",
+};
diff --git a/exp_auto_restart/package.json b/exp_auto_restart/package.json
new file mode 100644
index 00000000..c848a184
--- /dev/null
+++ b/exp_auto_restart/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@expcluster/auto-restart",
+ "version": "7.0.1",
+ "description": "Clusterio plugin restarting hosts and the controller once no players are online.",
+ "author": "Cooldude2606 ",
+ "license": "MIT",
+ "repository": "explosivegaming/ExpCluster",
+ "type": "module",
+ "main": "dist/node/index.js",
+ "scripts": {
+ "prepare": "tsc --build && webpack-cli --env production",
+ "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js",
+ "coverage": "tap --coverage-report=text test/*.test.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@clusterio/controller": "workspace:^",
+ "@clusterio/lib": "workspace:^"
+ },
+ "devDependencies": {
+ "@clusterio/controller": "workspace:^",
+ "@clusterio/lib": "workspace:^",
+ "@types/node": "catalog:",
+ "tap": "^21.1.0",
+ "typescript": "catalog:",
+ "webpack": "catalog:",
+ "webpack-cli": "catalog:",
+ "webpack-merge": "catalog:"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "keywords": [
+ "clusterio",
+ "clusterio-plugin",
+ "factorio"
+ ]
+}
diff --git a/exp_auto_restart/test/controller.test.js b/exp_auto_restart/test/controller.test.js
new file mode 100644
index 00000000..e7461f53
--- /dev/null
+++ b/exp_auto_restart/test/controller.test.js
@@ -0,0 +1,353 @@
+import t from "tap";
+import * as lib from "@clusterio/lib";
+import { Controller, HostRecord, InstanceRecord } from "@clusterio/controller";
+import entrypoint, { AutoRestart, PENDING_TIMEOUT_MS } from "../dist/node/controller.js";
+import { plugin as pluginDeclaration } from "../dist/node/index.js";
+
+// The plugin's config fields must be defined before a ControllerConfig can set them
+lib.addPluginConfigFields([pluginDeclaration]);
+
+// Silence the singleton logger to avoid polluting the test output
+lib.logger.silent = true;
+t.after(() => {
+ lib.logger.silent = false;
+});
+
+/** Build the plugin around a real controller, which is side effect free while not started. */
+async function startPlugin(t2, { config = {}, canRestart = false } = {}) {
+ const logs = [];
+ const logger = {
+ child: () => logger,
+ info: message => logs.push(["info", message]),
+ warn: message => logs.push(["warn", message]),
+ error: message => logs.push(["error", message]),
+ verbose: () => {},
+ };
+ const controllerConfig = new lib.ControllerConfig("controller", {
+ "controller.database_directory": t2.testdir(),
+ ...config,
+ });
+ const controller = new Controller(logger, [], controllerConfig, canRestart);
+
+ // Record what would be sent to hosts and instances instead of sending it
+ const sent = [];
+ controller.sendTo = async (address, message) => {
+ sent.push({ address, message });
+ };
+
+ const plugin = new AutoRestart({ controller, logger, plugin: pluginDeclaration, metrics: undefined });
+ plugin.start();
+ t2.teardown(() => plugin.stop());
+ return { plugin, controller, sent, logs };
+}
+
+function addHost(controller, id, connected = true) {
+ controller.hosts.set(new HostRecord(id, `host-${id}`, "2.0.0", new Map(), connected));
+}
+
+function addInstance(controller, id, hostId, status = "running", fields = {}) {
+ const config = new lib.InstanceConfig("controller", {
+ "instance.id": id,
+ "instance.name": `instance-${id}`,
+ "instance.assigned_host": hostId,
+ ...fields,
+ });
+ const record = new InstanceRecord(config, status);
+ controller.instances.records.set(record);
+ return record;
+}
+
+function addSystem(controller, id, { restartRequired = true, canRestart = true, processStartedAtMs = 1000 } = {}) {
+ controller.systems.set(new lib.SystemInfo(
+ id, "box", "v22", "Linux", "x64", "cpu", [], 0, 0, 0, 0,
+ canRestart, restartRequired, 0, processStartedAtMs, Date.now(), false,
+ ));
+}
+
+const join = (controller, name, instanceId) => controller.users.getOrCreateUser(name).notifyJoin(instanceId);
+const leave = (controller, name, instanceId) => controller.users.getOrCreateUser(name).notifyLeave(instanceId);
+const restartsSent = sent => sent.filter(({ message }) => message instanceof lib.HostRestartRequest);
+const startsSent = sent => sent.filter(({ message }) => message instanceof lib.InstanceStartRequest);
+
+t.test("entrypoint", async t2 => {
+ const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
+ const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
+ const controller = new Controller(logger, [], controllerConfig);
+ await entrypoint({ controller, logger, plugin: pluginDeclaration, metrics: undefined });
+ for (const hook of ["shutdown", "playerEvent", "instanceStatusChanged", "hostConnectionEvent"]) {
+ t2.ok([...controller.hooks[hook].attached].includes("exp_auto_restart"), `attached to ${hook}`);
+ }
+ await controller.hooks.shutdown.invoke();
+});
+
+t.test("class AutoRestart", t2 => {
+ t2.test(".onlinePlayers() counts players on running instances per host", async t3 => {
+ const { plugin, controller } = await startPlugin(t3);
+ addHost(controller, 1);
+ addHost(controller, 2);
+ addInstance(controller, 10, 1);
+ addInstance(controller, 11, 1, "stopped");
+ addInstance(controller, 20, 2);
+ join(controller, "alice", 10);
+ join(controller, "bob", 10);
+ join(controller, "carol", 11);
+ join(controller, "dave", 20);
+
+ t3.strictSame(plugin.onlinePlayers(), new Map([[1, 2], [2, 1]]), "players on stopped instances are not counted");
+ });
+
+ t2.test(".refreshIdle() tracks when hosts and the cluster became empty", async t3 => {
+ const { plugin, controller } = await startPlugin(t3);
+ addHost(controller, 1);
+ addHost(controller, 2);
+ addHost(controller, 3, false);
+ addInstance(controller, 10, 1);
+ join(controller, "alice", 10);
+
+ plugin.refreshIdle(1000);
+ t3.notOk(plugin.idleSince.has(1), "a host with players is not idle");
+ t3.strictSame(plugin.idleSince.get(2), 1000, "an empty host is idle from now");
+ t3.notOk(plugin.idleSince.has(3), "a disconnected host is not tracked");
+ t3.notOk(plugin.idleSince.has("cluster"), "the cluster is not idle while anyone is online");
+
+ leave(controller, "alice", 10);
+ plugin.refreshIdle(2000);
+ t3.strictSame(plugin.idleSince.get(1), 2000, "a host becomes idle when its last player leaves");
+ t3.strictSame(plugin.idleSince.get(2), 1000, "an idle host keeps its original time");
+ t3.strictSame(plugin.idleSince.get("cluster"), 2000, "the cluster becomes idle with the last player");
+
+ join(controller, "alice", 10);
+ plugin.refreshIdle(3000);
+ t3.notOk(plugin.idleSince.has(1), "a join clears the idle time");
+ t3.notOk(plugin.idleSince.has("cluster"));
+ });
+
+ t2.test(".check() restarts a host once it has been empty for long enough", async t3 => {
+ const { plugin, controller, sent } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 60 },
+ });
+ const now = Date.now();
+ addHost(controller, 1);
+ addInstance(controller, 10, 1);
+ addInstance(controller, 11, 1, "stopped");
+ addSystem(controller, 1);
+ join(controller, "alice", 10);
+
+ await plugin.check(now);
+ t3.strictSame(restartsSent(sent).length, 0, "not restarted while a player is online");
+
+ leave(controller, "alice", 10);
+ await plugin.check(now + 10_000);
+ t3.strictSame(restartsSent(sent).length, 0, "not restarted before the idle time has passed");
+
+ await plugin.check(now + 70_000);
+ t3.strictSame(restartsSent(sent).length, 1, "restarted once the idle time has passed");
+ t3.strictSame(restartsSent(sent)[0].address, { hostId: 1 }, "the restart is sent to the host");
+ t3.strictSame([...plugin.pending.get(1).instanceIds], [10], "only the running instances are remembered");
+
+ await plugin.check(now + 80_000);
+ t3.strictSame(restartsSent(sent).length, 1, "not restarted again while waiting for it to come back");
+ });
+
+ t2.test(".check() asks each host process only once", async t3 => {
+ const { plugin, controller, sent } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 0 },
+ });
+ const now = Date.now();
+ addHost(controller, 1);
+ addSystem(controller, 1, { processStartedAtMs: 1000 });
+
+ await plugin.check(now);
+ t3.strictSame(restartsSent(sent).length, 1, "restarted with no running instances");
+ t3.notOk(plugin.pending.has(1), "nothing to wait for without running instances");
+
+ await plugin.check(now + 10_000);
+ t3.strictSame(restartsSent(sent).length, 1, "the same process is not asked again");
+
+ addSystem(controller, 1, { restartRequired: false, processStartedAtMs: 2000 });
+ await plugin.check(now + 20_000);
+ t3.strictSame(restartsSent(sent).length, 1, "the new process does not need a restart");
+
+ addSystem(controller, 1, { processStartedAtMs: 2000 });
+ await plugin.check(now + 30_000);
+ t3.strictSame(restartsSent(sent).length, 2, "the new process is restarted when it needs it");
+ });
+
+ t2.test(".check() leaves hosts alone that cannot or need not restart", async t3 => {
+ const { plugin, controller, sent, logs } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 0 },
+ });
+ const now = Date.now();
+ addHost(controller, 1);
+ addSystem(controller, 1, { canRestart: false });
+ addHost(controller, 2);
+ addSystem(controller, 2, { restartRequired: false });
+ addHost(controller, 3, false);
+ addSystem(controller, 3);
+
+ await plugin.check(now);
+ await plugin.check(now + 10_000);
+ t3.strictSame(restartsSent(sent).length, 0, "no host is restarted");
+ const warnings = logs.filter(([level, message]) => level === "warn" && message.includes("--can-restart"));
+ t3.strictSame(warnings.length, 1, "a host without a process monitor is warned about once");
+ });
+
+ t2.test(".check() with scope cluster waits for the whole cluster to be empty", async t3 => {
+ const { plugin, controller, sent } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 0, "exp_auto_restart.scope": "cluster" },
+ });
+ const now = Date.now();
+ addHost(controller, 1);
+ addSystem(controller, 1);
+ addHost(controller, 2);
+ addInstance(controller, 20, 2);
+ join(controller, "alice", 20);
+
+ await plugin.check(now);
+ t3.strictSame(restartsSent(sent).length, 0, "a player on another host holds back the restart");
+
+ leave(controller, "alice", 20);
+ await plugin.check(now + 10_000);
+ t3.strictSame(restartsSent(sent).length, 1, "restarted once the cluster is empty");
+ });
+
+ t2.test(".restartHost() retries transport errors but not refusals", async t3 => {
+ const { plugin, controller, sent, logs } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 0 },
+ });
+ const now = Date.now();
+ addHost(controller, 1);
+ addInstance(controller, 10, 1);
+ addSystem(controller, 1);
+
+ controller.sendTo = async () => { throw new lib.RequestError("Cannot restart"); };
+ await plugin.check(now);
+ await plugin.check(now + 10_000);
+ t3.strictSame(logs.filter(([level]) => level === "error").length, 1, "a refusal is logged once");
+ t3.notOk(plugin.pending.has(1), "a refused restart is not waited on");
+
+ addSystem(controller, 1, { processStartedAtMs: 2000 });
+ let attempts = 0;
+ controller.sendTo = async () => { attempts += 1; throw new Error("Connection lost"); };
+ await plugin.check(now + 20_000);
+ await plugin.check(now + 30_000);
+ t3.strictSame(attempts, 2, "a transport error is retried on the next check");
+ });
+
+ t2.test(".instanceReported() starts the instances that were running", async t3 => {
+ const { plugin, controller, sent } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 0 },
+ });
+ const now = Date.now();
+ addHost(controller, 1);
+ const manual = addInstance(controller, 10, 1);
+ const auto = addInstance(controller, 11, 1, "running", { "instance.auto_start": true });
+ const stopped = addInstance(controller, 12, 1, "stopped");
+ addSystem(controller, 1);
+ await plugin.check(now);
+ t3.strictSame([...plugin.pending.get(1).instanceIds], [10, 11]);
+
+ // The host comes back and reports every instance as stopped
+ for (const instance of [manual, auto, stopped]) {
+ instance.status = "stopped";
+ await plugin.onInstanceStatusChanged(instance, "unknown");
+ }
+ t3.strictSame(startsSent(sent).map(({ address }) => address), [{ instanceId: 10 }], "only the instance the host will not start itself is started");
+ t3.notOk(plugin.pending.has(1), "the host is no longer waited on");
+
+ // An instance reporting in without a pending restart is ignored
+ await plugin.onInstanceStatusChanged(manual, "unknown");
+ t3.strictSame(startsSent(sent).length, 1);
+ });
+
+ t2.test(".restartHost() does not wait on instances when they are not to be started", async t3 => {
+ const { plugin, controller, sent } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 0, "exp_auto_restart.start_instances": false },
+ });
+ addHost(controller, 1);
+ const instance = addInstance(controller, 10, 1);
+ addSystem(controller, 1);
+ await plugin.check(Date.now());
+ t3.strictSame(restartsSent(sent).length, 1);
+ t3.notOk(plugin.pending.has(1), "nothing is waited on");
+
+ instance.status = "stopped";
+ await plugin.onInstanceStatusChanged(instance, "unknown");
+ t3.strictSame(startsSent(sent).length, 0, "nothing is started");
+ });
+
+ t2.test(".expirePending() gives up on hosts that never come back", async t3 => {
+ const { plugin, controller, logs } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 0 },
+ });
+ const now = Date.now();
+ addHost(controller, 1);
+ addInstance(controller, 10, 1);
+ addSystem(controller, 1);
+ await plugin.check(now);
+ t3.ok(plugin.pending.has(1));
+
+ await plugin.check(now + PENDING_TIMEOUT_MS - 1);
+ t3.ok(plugin.pending.has(1), "still waited on before the timeout");
+ await plugin.check(now + PENDING_TIMEOUT_MS);
+ t3.notOk(plugin.pending.has(1), "no longer waited on after the timeout");
+ t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("giving up")), "giving up is logged");
+ });
+
+ t2.test(".checkController() restarts the controller when the cluster is empty", async t3 => {
+ const { plugin, controller } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 60, "exp_auto_restart.restart_controller": true },
+ canRestart: true,
+ });
+ const now = Date.now();
+ let stopped = 0;
+ controller.stop = async () => { stopped += 1; };
+ controller.checkRestartDowngrade = async () => null;
+ addHost(controller, 1);
+ addInstance(controller, 10, 1);
+ addSystem(controller, "controller");
+ join(controller, "alice", 10);
+
+ await plugin.check(now);
+ t3.strictSame(stopped, 0, "not restarted while a player is online");
+
+ leave(controller, "alice", 10);
+ await plugin.check(now + 10_000);
+ t3.strictSame(stopped, 0, "not restarted before the idle time has passed");
+
+ await plugin.check(now + 70_000);
+ t3.strictSame(stopped, 1, "restarted once the cluster has been empty for long enough");
+ t3.ok(controller.shouldRestart, "stopped with the intent to restart");
+
+ await plugin.check(now + 80_000);
+ t3.strictSame(stopped, 1, "not restarted twice");
+ });
+
+ t2.test(".checkController() refuses downgrades and missing process monitors", async t3 => {
+ const { plugin, controller, logs } = await startPlugin(t3, {
+ config: { "exp_auto_restart.idle_seconds": 0, "exp_auto_restart.restart_controller": true },
+ canRestart: true,
+ });
+ let stopped = 0;
+ controller.stop = async () => { stopped += 1; };
+ controller.checkRestartDowngrade = async () => ({ installedVersion: "2.0.0", runningVersion: "2.0.1" });
+ addSystem(controller, "controller");
+
+ await plugin.check(Date.now());
+ t3.strictSame(stopped, 0, "not restarted into an older version");
+ t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("older")), "the downgrade is logged");
+
+ plugin.warned.clear();
+ addSystem(controller, "controller", { canRestart: false });
+ await plugin.check(Date.now());
+ t3.strictSame(stopped, 0, "not restarted without a process monitor");
+ t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("--can-restart")));
+ });
+
+ t2.test(".start() warns when system metrics are disabled", async t3 => {
+ const { logs } = await startPlugin(t3, { config: { "controller.system_metrics_interval": 0 } });
+ t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("system_metrics_interval")));
+ });
+
+ t2.end();
+});
diff --git a/exp_auto_restart/tsconfig.browser.json b/exp_auto_restart/tsconfig.browser.json
new file mode 100644
index 00000000..1ab2704e
--- /dev/null
+++ b/exp_auto_restart/tsconfig.browser.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../tsconfig.browser.json",
+ "include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
+}
diff --git a/exp_auto_restart/tsconfig.json b/exp_auto_restart/tsconfig.json
new file mode 100644
index 00000000..303b8f28
--- /dev/null
+++ b/exp_auto_restart/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.browser.json" }
+ ]
+}
diff --git a/exp_auto_restart/tsconfig.node.json b/exp_auto_restart/tsconfig.node.json
new file mode 100644
index 00000000..3218f2e7
--- /dev/null
+++ b/exp_auto_restart/tsconfig.node.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../tsconfig.node.json",
+ "include": ["./**/*.ts"],
+ "exclude": ["test/*", "./dist/*"],
+}
diff --git a/exp_auto_restart/web/index.tsx b/exp_auto_restart/web/index.tsx
new file mode 100644
index 00000000..e69de29b
diff --git a/exp_auto_restart/webpack.config.cjs b/exp_auto_restart/webpack.config.cjs
new file mode 100644
index 00000000..58150215
--- /dev/null
+++ b/exp_auto_restart/webpack.config.cjs
@@ -0,0 +1,29 @@
+"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.tsx",
+ output: {
+ path: path.resolve(__dirname, "dist", "web"),
+ },
+ plugins: [
+ new webpack.container.ModuleFederationPlugin({
+ name: "exp_auto_restart",
+ library: { type: "window", name: "plugin_exp_auto_restart" },
+ exposes: {
+ "./": "./index.ts",
+ "./package.json": "./package.json",
+ "./web": "./web/index.tsx",
+ },
+ shared: {
+ "@clusterio/lib": { import: false },
+ "@clusterio/web_ui": { import: false },
+ },
+ }),
+ ],
+});
diff --git a/exp_commands/index.ts b/exp_commands/index.ts
index 3e788480..58d11b1f 100644
--- a/exp_commands/index.ts
+++ b/exp_commands/index.ts
@@ -4,5 +4,5 @@ export const plugin: lib.PluginDeclaration = {
name: "exp_commands",
title: "exp_commands",
description: "Example Description. Plugin. Change me in index.ts",
- instanceEntrypoint: "./dist/node/instance",
+ instanceEntrypoint: "./dist/node/instance.js",
};
diff --git a/exp_commands/package.json b/exp_commands/package.json
index 7628cfeb..04b5f922 100644
--- a/exp_commands/package.json
+++ b/exp_commands/package.json
@@ -5,6 +5,7 @@
"author": "Cooldude2606 ",
"license": "MIT",
"repository": "explosivegaming/ExpCluster",
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production"
diff --git a/exp_commands/webpack.config.js b/exp_commands/webpack.config.cjs
similarity index 100%
rename from exp_commands/webpack.config.js
rename to exp_commands/webpack.config.cjs
diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts
index 14d13ef2..846fc0fd 100644
--- a/exp_groups/controller.ts
+++ b/exp_groups/controller.ts
@@ -1,15 +1,23 @@
-import { BaseControllerPlugin } from "@clusterio/controller";
+import { BaseControllerPlugin, type Controller } from "@clusterio/controller";
import * as lib from "@clusterio/lib";
-import * as messages from "./messages";
+import * as messages from "./messages.js";
import * as path from "node:path";
+const loaded = new WeakMap();
+
export class ControllerPlugin extends BaseControllerPlugin {
groups!: lib.SubscribableDatastore;
roleMappings!: lib.SubscribableDatastore;
manualAssignments!: lib.SubscribableDatastore;
resolvedAssignments!: lib.SubscribableDatastore;
+ /** The plugin loaded on a controller, for the exp_scenario seed. */
+ static get(controller: Controller) {
+ return loaded.get(controller);
+ }
+
async init() {
+ loaded.set(this.controller, this);
const databaseDirectory = this.controller.config.get("controller.database_directory");
this.groups = new lib.SubscribableDatastore(
diff --git a/exp_groups/index.ts b/exp_groups/index.ts
index 524ac7d9..c92c3e6b 100644
--- a/exp_groups/index.ts
+++ b/exp_groups/index.ts
@@ -1,5 +1,8 @@
import * as lib from "@clusterio/lib";
-import * as messages from "./messages";
+import * as messages from "./messages.js";
+
+export * from "./messages.js";
+export type { ControllerPlugin } from "./controller.js";
declare module "@clusterio/lib" {
export interface InstanceConfigFields {
@@ -9,123 +12,6 @@ declare module "@clusterio/lib" {
}
}
-// Group permissions
-
-lib.definePermission({
- name: "exp_groups.group.get",
- title: "Get Groups",
- description: "Retrieve a specific Factorio permission group by ID.",
- grantByDefault: true,
-});
-lib.definePermission({
- name: "exp_groups.group.list",
- title: "List Groups",
- description: "List all Factorio permission groups.",
- grantByDefault: true,
-});
-lib.definePermission({
- name: "exp_groups.group.subscribe",
- title: "Subscribe to Group Updates",
- description: "Receive updates when Factorio permission groups change.",
- grantByDefault: true,
-});
-lib.definePermission({
- name: "exp_groups.group.create",
- title: "Create Groups",
- description: "Create new Factorio permission groups.",
- grantByDefault: false,
-});
-lib.definePermission({
- name: "exp_groups.group.update",
- title: "Update Groups",
- description: "Modify existing Factorio permission groups.",
- grantByDefault: false,
-});
-lib.definePermission({
- name: "exp_groups.group.delete",
- title: "Delete Groups",
- description: "Delete Factorio permission groups.",
- grantByDefault: false,
-});
-
-// Assignment permissions
-
-lib.definePermission({
- name: "exp_groups.assignment.get",
- title: "Get Assignments",
- description: "Retrieve a specific manual group assignment for a player.",
- grantByDefault: true,
-});
-lib.definePermission({
- name: "exp_groups.assignment.list",
- title: "List Assignments",
- description: "List all manual group assignments.",
- grantByDefault: true,
-});
-lib.definePermission({
- name: "exp_groups.assignment.subscribe",
- title: "Subscribe to Assignment Updates",
- description: "Receive updates when manual group assignments change.",
- grantByDefault: true,
-});
-lib.definePermission({
- name: "exp_groups.assignment.create",
- title: "Create Assignments",
- description: "Manually assign players to groups, overriding role mappings.",
- grantByDefault: false,
-});
-lib.definePermission({
- name: "exp_groups.assignment.update",
- title: "Update Assignments",
- description: "Modify existing manual group assignments.",
- grantByDefault: false,
-});
-lib.definePermission({
- name: "exp_groups.assignment.delete",
- title: "Delete Assignments",
- description: "Remove manual group assignments.",
- grantByDefault: false,
-});
-
-// Role mapping permissions
-
-lib.definePermission({
- name: "exp_groups.role_mapping.get",
- title: "Get Role Mappings",
- description: "Retrieve a specific role mapping rule.",
- grantByDefault: true,
-});
-lib.definePermission({
- name: "exp_groups.role_mapping.list",
- title: "List Role Mappings",
- description: "List all role mapping rules.",
- grantByDefault: true,
-});
-lib.definePermission({
- name: "exp_groups.role_mapping.subscribe",
- title: "Subscribe to Role Mapping Updates",
- description: "Receive updates when role mapping rules change.",
- grantByDefault: true,
-});
-lib.definePermission({
- name: "exp_groups.role_mapping.create",
- title: "Create Role Mappings",
- description: "Create rules that map user roles to Factorio permission groups.",
- grantByDefault: false,
-});
-lib.definePermission({
- name: "exp_groups.role_mapping.update",
- title: "Update Role Mappings",
- description: "Modify existing role mapping rules.",
- grantByDefault: false,
-});
-lib.definePermission({
- name: "exp_groups.role_mapping.delete",
- title: "Delete Role Mappings",
- description: "Delete role mapping rules.",
- grantByDefault: false,
-});
-
export const plugin: lib.PluginDeclaration = {
name: "exp_groups",
title: "ExpGaming - Permission Groups",
@@ -161,7 +47,126 @@ export const plugin: lib.PluginDeclaration = {
messages.RoleMappingListRequest,
],
- instanceEntrypoint: "./dist/node/instance",
+ permissions: [
+ // Group permissions
+
+ {
+ name: "exp_groups.group.get",
+ title: "Get Groups",
+ description: "Retrieve a specific Factorio permission group by ID.",
+ grantByDefault: true,
+ },
+ {
+ name: "exp_groups.group.list",
+ title: "List Groups",
+ description: "List all Factorio permission groups.",
+ grantByDefault: true,
+ },
+ {
+ name: "exp_groups.group.subscribe",
+ title: "Subscribe to Group Updates",
+ description: "Receive updates when Factorio permission groups change.",
+ grantByDefault: true,
+ },
+ {
+ name: "exp_groups.group.create",
+ title: "Create Groups",
+ description: "Create new Factorio permission groups.",
+ grantByDefault: false,
+ },
+ {
+ name: "exp_groups.group.update",
+ title: "Update Groups",
+ description: "Modify existing Factorio permission groups.",
+ grantByDefault: false,
+ },
+ {
+ name: "exp_groups.group.delete",
+ title: "Delete Groups",
+ description: "Delete Factorio permission groups.",
+ grantByDefault: false,
+ },
+
+ // Assignment permissions
+
+ {
+ name: "exp_groups.assignment.get",
+ title: "Get Assignments",
+ description: "Retrieve a specific manual group assignment for a player.",
+ grantByDefault: true,
+ },
+ {
+ name: "exp_groups.assignment.list",
+ title: "List Assignments",
+ description: "List all manual group assignments.",
+ grantByDefault: true,
+ },
+ {
+ name: "exp_groups.assignment.subscribe",
+ title: "Subscribe to Assignment Updates",
+ description: "Receive updates when manual group assignments change.",
+ grantByDefault: true,
+ },
+ {
+ name: "exp_groups.assignment.create",
+ title: "Create Assignments",
+ description: "Manually assign players to groups, overriding role mappings.",
+ grantByDefault: false,
+ },
+ {
+ name: "exp_groups.assignment.update",
+ title: "Update Assignments",
+ description: "Modify existing manual group assignments.",
+ grantByDefault: false,
+ },
+ {
+ name: "exp_groups.assignment.delete",
+ title: "Delete Assignments",
+ description: "Remove manual group assignments.",
+ grantByDefault: false,
+ },
+
+ // Role mapping permissions
+
+ {
+ name: "exp_groups.role_mapping.get",
+ title: "Get Role Mappings",
+ description: "Retrieve a specific role mapping rule.",
+ grantByDefault: true,
+ },
+ {
+ name: "exp_groups.role_mapping.list",
+ title: "List Role Mappings",
+ description: "List all role mapping rules.",
+ grantByDefault: true,
+ },
+ {
+ name: "exp_groups.role_mapping.subscribe",
+ title: "Subscribe to Role Mapping Updates",
+ description: "Receive updates when role mapping rules change.",
+ grantByDefault: true,
+ },
+ {
+ name: "exp_groups.role_mapping.create",
+ title: "Create Role Mappings",
+ description: "Create rules that map user roles to Factorio permission groups.",
+ grantByDefault: false,
+ },
+ {
+ name: "exp_groups.role_mapping.update",
+ title: "Update Role Mappings",
+ description: "Modify existing role mapping rules.",
+ grantByDefault: false,
+ },
+ {
+ name: "exp_groups.role_mapping.delete",
+ title: "Delete Role Mappings",
+ description: "Delete role mapping rules.",
+ grantByDefault: false,
+ },
+ ],
+
+ instanceEntrypoint: "./dist/node/instance.js",
instanceConfigFields: {
"exp_groups.sync_mode": {
description: "Synchronize permission groups with the controller",
@@ -171,7 +176,7 @@ export const plugin: lib.PluginDeclaration = {
},
},
- controllerEntrypoint: "./dist/node/controller",
+ controllerEntrypoint: "./dist/node/controller.js",
controllerConfigFields: {
},
diff --git a/exp_groups/instance.ts b/exp_groups/instance.ts
index d3ed3cc4..207b969c 100644
--- a/exp_groups/instance.ts
+++ b/exp_groups/instance.ts
@@ -1,6 +1,6 @@
import { BaseInstancePlugin } from "@clusterio/host";
import * as lib from "@clusterio/lib";
-import * as messages from "./messages";
+import * as messages from "./messages.js";
export type IpcGroupUpdated = {
group_name: string,
diff --git a/exp_groups/messages.ts b/exp_groups/messages.ts
index b0e5e986..d5191ca9 100644
--- a/exp_groups/messages.ts
+++ b/exp_groups/messages.ts
@@ -1,6 +1,30 @@
import * as lib from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
+// Declared here rather than in index.ts because the browser project cannot include index.ts, which re-exports controller.ts
+declare module "@clusterio/lib" {
+ export interface Permissions {
+ "exp_groups.group.get": never;
+ "exp_groups.group.list": never;
+ "exp_groups.group.subscribe": never;
+ "exp_groups.group.create": never;
+ "exp_groups.group.update": never;
+ "exp_groups.group.delete": never;
+ "exp_groups.assignment.get": never;
+ "exp_groups.assignment.list": never;
+ "exp_groups.assignment.subscribe": never;
+ "exp_groups.assignment.create": never;
+ "exp_groups.assignment.update": never;
+ "exp_groups.assignment.delete": never;
+ "exp_groups.role_mapping.get": never;
+ "exp_groups.role_mapping.list": never;
+ "exp_groups.role_mapping.subscribe": never;
+ "exp_groups.role_mapping.create": never;
+ "exp_groups.role_mapping.update": never;
+ "exp_groups.role_mapping.delete": never;
+ }
+}
+
/*
Data records
*/
diff --git a/exp_groups/module/control.lua b/exp_groups/module/control.lua
index 232fd6e3..6203b1c5 100644
--- a/exp_groups/module/control.lua
+++ b/exp_groups/module/control.lua
@@ -102,11 +102,11 @@ local function encode_group_permissions(group)
local allows_action = group.allows_action
for input_action_name, input_action in pairs(defines.input_action) do
if allows_action(input_action) then
- whitelist[whitelist_index] = input_action_name
whitelist_index = whitelist_index + 1
+ whitelist[whitelist_index] = input_action_name
else
- blacklist[blacklist_index] = input_action_name
blacklist_index = blacklist_index + 1
+ blacklist[blacklist_index] = input_action_name
end
end
diff --git a/exp_groups/package.json b/exp_groups/package.json
index 0bd0dae8..6e055307 100644
--- a/exp_groups/package.json
+++ b/exp_groups/package.json
@@ -5,6 +5,7 @@
"author": "Cooldude2606 ",
"license": "MIT",
"repository": "explosivegaming/ExpCluster",
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production"
diff --git a/exp_groups/web/components/AssignmentForm.tsx b/exp_groups/web/components/AssignmentForm.tsx
index 41a6a8f2..f20b21a2 100644
--- a/exp_groups/web/components/AssignmentForm.tsx
+++ b/exp_groups/web/components/AssignmentForm.tsx
@@ -2,7 +2,7 @@ import React, { useContext, useEffect } from "react";
import { Modal, Form, Select } from "antd";
import { ControlContext, useUsers } from "@clusterio/web_ui";
-import * as messages from "../../messages";
+import * as messages from "../../messages.js";
import type { WebPlugin } from "..";
export default function AssignmentForm({ open, setOpen, initial }: {
diff --git a/exp_groups/web/components/AssignmentsTable.tsx b/exp_groups/web/components/AssignmentsTable.tsx
index 680d9f78..f6997811 100644
--- a/exp_groups/web/components/AssignmentsTable.tsx
+++ b/exp_groups/web/components/AssignmentsTable.tsx
@@ -3,7 +3,7 @@ import { Table, Button, Space, Input, InputRef } from "antd";
import { EditOutlined, SearchOutlined } from "@ant-design/icons";
import { ControlContext, useAccount } from "@clusterio/web_ui";
-import { AssignmentDeleteRequest, AssignmentRecord } from "../../messages";
+import { AssignmentDeleteRequest, AssignmentRecord } from "../../messages.js";
import type { WebPlugin } from "..";
import AssignmentForm from "./AssignmentForm";
diff --git a/exp_groups/web/components/GroupForm.tsx b/exp_groups/web/components/GroupForm.tsx
index 14e3aa1a..945f3c59 100644
--- a/exp_groups/web/components/GroupForm.tsx
+++ b/exp_groups/web/components/GroupForm.tsx
@@ -3,7 +3,7 @@ import { Modal, Form, Input, Switch } from "antd";
import { useNavigate } from "react-router-dom";
import { ControlContext } from "@clusterio/web_ui";
-import * as messages from "../../messages";
+import * as messages from "../../messages.js";
export default function GroupForm({ open, setOpen }: {
open: boolean,
diff --git a/exp_groups/web/components/GroupViewPage.tsx b/exp_groups/web/components/GroupViewPage.tsx
index 1d5d31fe..1f5ec8ac 100644
--- a/exp_groups/web/components/GroupViewPage.tsx
+++ b/exp_groups/web/components/GroupViewPage.tsx
@@ -5,7 +5,7 @@ import { Button, Checkbox, Input, Space, Spin, Alert } from "antd";
import { ControlContext, useAccount, useDefaultModPack, PageLayout, PageHeader, notifyErrorHandler } from "@clusterio/web_ui";
import DeletedConfirm from "./DeleteConfirm";
-import * as messages from "../../messages";
+import * as messages from "../../messages.js";
import type { WebPlugin } from "..";
const DOMAIN_MAPPING = {
diff --git a/exp_groups/web/components/GroupsTable.tsx b/exp_groups/web/components/GroupsTable.tsx
index 1dc711d9..d3f6c9b9 100644
--- a/exp_groups/web/components/GroupsTable.tsx
+++ b/exp_groups/web/components/GroupsTable.tsx
@@ -3,7 +3,7 @@ import { Table } from "antd";
import { useNavigate } from "react-router-dom";
import { ControlContext } from "@clusterio/web_ui";
-import { GroupRecord } from "../../messages";
+import { GroupRecord } from "../../messages.js";
import type { WebPlugin } from "..";
import GroupForm from "./GroupForm";
diff --git a/exp_groups/web/components/RoleMappingForm.tsx b/exp_groups/web/components/RoleMappingForm.tsx
index ee143301..59918b29 100644
--- a/exp_groups/web/components/RoleMappingForm.tsx
+++ b/exp_groups/web/components/RoleMappingForm.tsx
@@ -2,7 +2,7 @@ import React, { useContext, useEffect } from "react";
import { Modal, Form, Select, InputNumber, Switch, Alert } from "antd";
import { ControlContext, useRoles } from "@clusterio/web_ui";
-import { RoleMappingRecord, RoleMappingCreateRequest, RoleMappingUpdateRequest } from "../../messages";
+import { RoleMappingRecord, RoleMappingCreateRequest, RoleMappingUpdateRequest } from "../../messages.js";
import type { WebPlugin } from "..";
export default function RoleMappingForm({ open, setOpen, initial }: {
diff --git a/exp_groups/web/components/RoleMappingsTable.tsx b/exp_groups/web/components/RoleMappingsTable.tsx
index 05877426..ab948960 100644
--- a/exp_groups/web/components/RoleMappingsTable.tsx
+++ b/exp_groups/web/components/RoleMappingsTable.tsx
@@ -3,7 +3,7 @@ import { Table, Button, Space, Tag } from "antd";
import { EditOutlined } from "@ant-design/icons";
import { ControlContext, useAccount, useRoles } from "@clusterio/web_ui";
-import { RoleMappingRecord, RoleMappingDeleteRequest } from "../../messages";
+import { RoleMappingRecord, RoleMappingDeleteRequest } from "../../messages.js";
import type { WebPlugin } from "..";
import RoleMappingForm from "./RoleMappingForm";
diff --git a/exp_groups/web/index.tsx b/exp_groups/web/index.tsx
index e9fbc797..5a2f24cd 100644
--- a/exp_groups/web/index.tsx
+++ b/exp_groups/web/index.tsx
@@ -2,7 +2,7 @@ import React, { useState, useCallback, useSyncExternalStore } from "react";
import { BaseWebPlugin, PageLayout, PageHeader, useAccount, SectionHeader } from "@clusterio/web_ui";
import { Button } from "antd";
-import * as messages from "../messages";
+import * as messages from "../messages.js";
import * as lib from "@clusterio/lib";
import GroupsTable from "./components/GroupsTable";
diff --git a/exp_groups/webpack.config.js b/exp_groups/webpack.config.cjs
similarity index 100%
rename from exp_groups/webpack.config.js
rename to exp_groups/webpack.config.cjs
diff --git a/exp_gui/index.ts b/exp_gui/index.ts
index eb9596f4..b25ce074 100644
--- a/exp_gui/index.ts
+++ b/exp_gui/index.ts
@@ -4,5 +4,5 @@ export const plugin: lib.PluginDeclaration = {
name: "exp_gui",
title: "exp_gui",
description: "Example Description. Plugin. Change me in index.ts",
- instanceEntrypoint: "./dist/node/instance",
+ instanceEntrypoint: "./dist/node/instance.js",
};
diff --git a/exp_gui/package.json b/exp_gui/package.json
index 6679512a..5f344d11 100644
--- a/exp_gui/package.json
+++ b/exp_gui/package.json
@@ -5,6 +5,7 @@
"author": "Cooldude2606 ",
"license": "MIT",
"repository": "explosivegaming/ExpCluster",
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production"
diff --git a/exp_gui/webpack.config.js b/exp_gui/webpack.config.cjs
similarity index 100%
rename from exp_gui/webpack.config.js
rename to exp_gui/webpack.config.cjs
diff --git a/exp_legacy/index.ts b/exp_legacy/index.ts
index ff35fce5..c49d3201 100644
--- a/exp_legacy/index.ts
+++ b/exp_legacy/index.ts
@@ -4,5 +4,5 @@ export const plugin: lib.PluginDeclaration = {
name: "exp_legacy",
title: "exp_legacy",
description: "Example Description. Plugin. Change me in index.ts",
- instanceEntrypoint: "./dist/node/instance",
+ instanceEntrypoint: "./dist/node/instance.js",
};
diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua
index 88fdec5e..d9eb6ad4 100644
--- a/exp_legacy/module/config/_file_loader.lua
+++ b/exp_legacy/module/config/_file_loader.lua
@@ -6,9 +6,6 @@
return {
"expcore.player_data", -- must be loaded first to register event handlers
- -- Control
- "modules.control.vlayer",
-
--- Data
"modules.data.statistics",
"modules.data.player-colours",
@@ -17,13 +14,5 @@ return {
"modules.data.alt-view",
"modules.data.tag",
"modules.data.language",
- --"modules.data.toolbar",
-
- --- GUI
- "modules.gui.warp-list",
- "modules.gui.vlayer",
-
"modules.graftorio.require", -- graftorio
- --- Config Files
- "config.expcore.permission_groups", -- loads some predefined permission groups
}
diff --git a/exp_legacy/module/config/expcore/permission_groups.lua b/exp_legacy/module/config/expcore/permission_groups.lua
deleted file mode 100644
index b0e972f3..00000000
--- a/exp_legacy/module/config/expcore/permission_groups.lua
+++ /dev/null
@@ -1,143 +0,0 @@
---- Use this file to add new permission groups to the game;
--- start with Permission_Groups.new_group('name');
--- then use either :allow_all() or :disallow_all() to set the default for non specified actions;
--- then use :allow{} and :disallow{} to specify certain actions to allow/disallow
--- @config Permission-Groups
-
--- local Event = require("modules/exp_legacy/utils/event") -- @dep utils.event
-local Groups = require("modules.exp_legacy.expcore.permission_groups") --- @dep expcore.permission_groups
-
-Groups.new_group("Admin")
- :allow_all()
- :disallow{
- "add_permission_group", -- admin
- "delete_permission_group",
- "edit_permission_group",
- "import_permissions_string",
- "map_editor_action",
- "toggle_map_editor",
- "change_multiplayer_config",
- "set_heat_interface_mode",
- "set_heat_interface_temperature",
- "set_infinity_container_filter_item",
- "set_infinity_container_remove_unfiltered_items",
- "set_infinity_pipe_filter",
- }
-
-Groups.new_group("Trusted")
- :allow_all()
- :disallow{
- "add_permission_group", -- admin
- "delete_permission_group",
- "edit_permission_group",
- "import_permissions_string",
- "map_editor_action",
- "toggle_map_editor",
- "change_multiplayer_config",
- "set_heat_interface_mode",
- "set_heat_interface_temperature",
- "set_infinity_container_filter_item",
- "set_infinity_container_remove_unfiltered_items",
- "set_infinity_pipe_filter",
- "admin_action", -- trusted
- }
-
-Groups.new_group("Standard")
- :allow_all()
- :disallow{
- "add_permission_group", -- admin
- "delete_permission_group",
- "edit_permission_group",
- "import_permissions_string",
- "map_editor_action",
- "toggle_map_editor",
- "change_multiplayer_config",
- "set_heat_interface_mode",
- "set_heat_interface_temperature",
- "set_infinity_container_filter_item",
- "set_infinity_container_remove_unfiltered_items",
- "set_infinity_pipe_filter",
- "admin_action", -- trusted
- "change_programmable_speaker_alert_parameters", -- standard
- "drop_item",
- "open_new_platform_button_from_rocket_silo",
- "set_rocket_silo_send_to_orbit_automated_mode",
- }
-
-Groups.new_group("Guest")
- :allow_all()
- :disallow{
- "add_permission_group", -- admin
- "delete_permission_group",
- "edit_permission_group",
- "import_permissions_string",
- "map_editor_action",
- "toggle_map_editor",
- "change_multiplayer_config",
- "set_heat_interface_mode",
- "set_heat_interface_temperature",
- "set_infinity_container_filter_item",
- "set_infinity_container_remove_unfiltered_items",
- "set_infinity_pipe_filter",
- "admin_action", -- trusted
- "change_programmable_speaker_alert_parameters", -- standard
- "drop_item",
- "open_new_platform_button_from_rocket_silo",
- "set_rocket_silo_send_to_orbit_automated_mode",
- "change_programmable_speaker_parameters", -- guest
- "change_train_stop_station",
- -- 'deconstruct',
- "remove_cables",
- "remove_train_station",
- "reset_assembling_machine",
- "rotate_entity",
- -- 'use_artillery_remote', -- not in 2.0
- "launch_rocket",
- "cancel_research",
- -- 'activate_cut', -- not in 2.0
- "flush_opened_entity_fluid",
- "flush_opened_entity_specific_fluid",
- }
-
-Groups.new_group("Restricted")
- :disallow_all()
- :allow("write_to_console")
-
---[[ These events are used until a role system is added to make it easier for our admins
-
-local trusted_time = 60*60*60*10 -- 10 hour
-local standard_time = 60*60*60*3 -- 3 hour
-local function assign_group(player)
- local current_group_name = player.permission_group and player.permission_group.name or 'None'
- if player.admin then
- Permission_Groups.set_player_group(player,'Admin')
- elseif player.online_time > trusted_time or current_group_name == 'Trusted' then
- Permission_Groups.set_player_group(player,'Trusted')
- elseif player.online_time > standard_time or current_group_name == 'Standard' then
- Permission_Groups.set_player_group(player,'Standard')
- else
- Permission_Groups.set_player_group(player,'Guest')
- end
-end
-
-Event.add(defines.events.on_player_joined_game,function(event)
- local player = game.players[event.player_index]
- assign_group(player)
-end)
-
-Event.add(defines.events.on_player_promoted,function(event)
- local player = game.players[event.player_index]
- assign_group(player)
-end)
-
-Event.add(defines.events.on_player_demoted,function(event)
- local player = game.players[event.player_index]
- assign_group(player)
-end)
-
-local check_interval = 60*60*15 -- 15 minutes
-Event.on_nth_tick(check_interval,function(event)
- for _,player in pairs(game.connected_players) do
- assign_group(player)
- end
-end)]]
diff --git a/exp_legacy/module/expcore/permission_groups.lua b/exp_legacy/module/expcore/permission_groups.lua
deleted file mode 100644
index edb0a315..00000000
--- a/exp_legacy/module/expcore/permission_groups.lua
+++ /dev/null
@@ -1,355 +0,0 @@
---[[-- Core Module - Permission Groups
-- Permission group making for factorio so you never have to make one by hand again
-@core Groups
-@alias Permissions_Groups
-
-@usage--- Example Group (Allow All)
--- here we will create an admin group however we do not want them to use the map editor or mess with the permission groups
-Permission_Groups.new_group('Admin') -- this defines a new group called "Admin"
-:allow_all() -- this makes the default to allow any input action unless set other wise
-:disallow{ -- here we disallow the input action we don't want them to use
- 'add_permission_group',
- 'delete_permission_group',
- 'import_permissions_string',
- 'map_editor_action',
- 'toggle_map_editor'
-}
-
-@usage--- Example Group (Disallow All)
--- here we will create a group that cant do anything but talk in chat
-Permission_Groups.new_group('Restricted') -- this defines a new group called "Restricted"
-:disallow_all() -- this makes the default to disallow any input action unless set other wise
-:allow('write_to_console') -- here we allow them to chat, {} can be used here if we had more than one action
-
-]]
-
-local Event = require("modules/exp_legacy/utils/event")
-local Async = require("modules/exp_util/async")
-
-local PermissionsGroups = {
- groups = {}, -- store for the different groups that are created
- _prototype = {}, -- stores functions that are used on group instances
-}
-
--- Async function to add players to permission groups
-local add_to_permission_group_async =
- Async.register(function(permission_group, player)
- permission_group.add_player(player)
- end)
-PermissionsGroups.add_to_permission_group_async = add_to_permission_group_async
-
--- Async function to remove players from permission groups
-local remove_from_permission_group_async =
- Async.register(function(permission_group, player)
- permission_group.remove_player(player)
- end)
-PermissionsGroups.remove_from_permission_group_async = remove_from_permission_group_async
-
---- Getters.
--- Functions that get permission groups
--- @section getters
-
---[[-- Defines a new permission group that can have it actions set in the config
-@tparam string name the name of the new group
-@treturn Permissions_Groups._prototype the new group made with function to allow and disallow actions
-
-@usage-- Defining a new permission group
-Groups.new_group('Admin')
-
-]]
-function PermissionsGroups.new_group(name)
- local group = setmetatable({
- name = name,
- actions = {},
- allow_all_actions = true,
- }, {
- __index = PermissionsGroups._prototype,
- })
- PermissionsGroups.groups[name] = group
- return group
-end
-
---[[-- Returns the group with the given name, case sensitive
-@tparam string name the name of the group to get
-@treturn ?Permissions_Groups._prototype|nil the group with that name or nil if non found
-
-@usage-- Getting a permision group
-local admin_group = Groups.get_group_by_name('Admin')
-
-]]
-function PermissionsGroups.get_group_by_name(name)
- return PermissionsGroups.groups[name]
-end
-
---[[-- Returns the group that a player is in
-@tparam LuaPlayer player the player to get the group of can be name index etc
-@treturn ?Permissions_Groups._prototype|nil the group with that player or nil if non found
-
-@usage-- Get your permission group
-local group = Groups.get_group_from_player(game.player)
-
-]]
-function PermissionsGroups.get_group_from_player(player)
- local group = player.permission_group
- if group then
- return PermissionsGroups.groups[group.name]
- end
-end
-
---- Setters.
--- Functions that control all groups
--- @section players
-
---[[-- Reloads/creates all permission groups and sets them to they configured state
-
-@usage-- Reload the permission groups, used internally
-Groups.reload_permissions()
-
-]]
-function PermissionsGroups.reload_permissions()
- for _, group in pairs(PermissionsGroups.groups) do
- group:create()
- end
-end
-
---[[-- Sets a player's group to the one given, a player can only have one group at a time
-@tparam LuaPlayer player the player to effect can be name index etc
-@tparam string group the name of the group to give to the player
-@treturn boolean true if the player was added successfully, false other wise
-
-@usage-- Set your permission group
-Groups.set_player_group(game.player, 'Admin')
-
-]]
-function PermissionsGroups.set_player_group(player, group)
- group = PermissionsGroups.get_group_by_name(group)
- if not group or not player then return false end
- group:add_player(player)
- return true
-end
-
---- Actions.
--- Functions that control group actions
--- @section actions
-
---[[-- Sets the allow state of an action for this group, used internally but is safe to use else where
-@tparam ?string|defines.input_action action the action that you want to set the state of
-@tparam boolean state the state that you want to set it to, true = allow, false = disallow
-@treturn Permissions_Groups._prototype returns self so function can be chained
-
-@usage-- Set an action to be disallowed
-group:set_action('toggle_map_editor', false)
-
-]]
-function PermissionsGroups._prototype:set_action(action, state)
- local input_action = defines.input_action[action] --[[@as defines.input_action?]]
- if input_action == nil then input_action = action end
- assert(type(input_action) == "number", tostring(action) .. " is not a valid input action")
- self.actions[input_action] = state
- return self
-end
-
---[[-- Sets an action or actions to be allowed for this group even with disallow_all triggered, Do not use in runtime
-@tparam string|Array actions the action or actions that you want to allow for this group
-@treturn Permissions_Groups._prototype returns self so function can be chained
-
-@usage-- Allow some actions
-group:allow{
- 'write_to_console'
-}
-
-]]
-function PermissionsGroups._prototype:allow(actions)
- if type(actions) ~= "table" then
- actions = { actions }
- end
- for _, action in pairs(actions) do
- self:set_action(action, true)
- end
-
- return self
-end
-
---[[-- Sets an action or actions to be disallowed for this group even with allow_all triggered, Do not use in runtime
-@tparam string|Array actions the action or actions that you want to disallow for this group
-@treturn Permissions_Groups._prototype returns self so function can be chained
-
-@usage-- Disalow some actions
-group:disallow{
- 'add_permission_group',
- 'delete_permission_group',
- 'import_permissions_string',
- 'map_editor_action',
- 'toggle_map_editor'
-}
-
-]]
-function PermissionsGroups._prototype:disallow(actions)
- if type(actions) ~= "table" then
- actions = { actions }
- end
- for _, action in pairs(actions) do
- self:set_action(action, false)
- end
-
- return self
-end
-
---[[-- Sets the default state for any actions not given to be allowed, useful with :disallow
-@treturn Permissions_Groups._prototype returns self so function can be chained
-
-@usage-- Allow all actions unless given by disallow
-group:allow_all()
-
-]]
-function PermissionsGroups._prototype:allow_all()
- self.allow_all_actions = true
- return self
-end
-
---[[-- Sets the default state for any action not given to be disallowed, useful with :allow
-@treturn Permissions_Groups._prototype returns self so function can be chained
-
-@usage-- Disallow all actions unless given by allow
-group:disallow_all()
-
-]]
-function PermissionsGroups._prototype:disallow_all()
- self.allow_all_actions = false
- return self
-end
-
---[[-- Returns if an input action is allowed for this group
-@tparam ?string|defines.input_action action the action that you want to test for
-@treturn boolean true if the group is allowed the action, false other wise
-
-@usage-- Test if a group is allowed an action
-local allowed = group:is_allowed('write_to_console')
-
-]]
-function PermissionsGroups._prototype:is_allowed(action)
- if type(action) == "string" then
- action = defines.input_action[action]
- end
- local state = self.actions[action]
- if state == nil then
- state = self.allow_all_actions
- end
- return state
-end
-
---- Players.
--- Functions that control group players
--- @section players
-
---[[-- Creates or updates the permission group with the configured actions, used internally
-@treturn LuaPermissionGroup the permission group that was created
-
-@usage-- Create the permission group so players can be added, used internally
-group:create()
-
-]]
-function PermissionsGroups._prototype:create()
- local group = self:get_raw()
- if not group then
- group = game.permissions.create_group(self.name) --[[@as LuaPermissionGroup]]
- end
- for _, action in pairs(defines.input_action) do
- group.set_allows_action(action, self:is_allowed(action))
- end
-
- return group
-end
-
---[[-- Returns the LuaPermissionGroup that was created with this group object, used internally
-@treturn LuaPermissionGroup the raw lua permission group
-
-@usage-- Get the factorio api permision group, used internally
-local permission_group = group:get_raw()
-
-]]
-function PermissionsGroups._prototype:get_raw()
- return game.permissions.get_group(self.name)
-end
-
---[[-- Adds a player to this group
-@tparam LuaPlayer player LuaPlayer the player you want to add to this group can be name or index etc
-@treturn boolean true if the player was added successfully, false other wise
-
-@usage-- Add a player to this permission group
-group:add_player(game.player)
-
-]]
-function PermissionsGroups._prototype:add_player(player)
- local group = self:get_raw()
- if not group or not player then return false end
- add_to_permission_group_async(group, player)
- return true
-end
-
---[[-- Removes a player from this group
-@tparam LuaPlayer player LuaPlayer the player you want to remove from this group can be name or index etc
-@treturn boolean true if the player was removed successfully, false other wise
-
-@usage-- Remove a player from this permission group
-group:remove_player(game.player)
-
-]]
-function PermissionsGroups._prototype:remove_player(player)
- local group = self:get_raw()
- if not group or not player then return false end
- remove_from_permission_group_async(group, player)
- return true
-end
-
---[[-- Returns all player that are in this group with the option to filter to online/offline only
-@tparam[opt] boolean online if nil returns all players, if true online players only, if false returns online players only
-@treturn table a table of players that are in this group; filtered if online param is given
-
-@usage-- Get all players in this group
-local online_players = group:get_players()
-
-@usage-- Get all online players in this group
-local online_players = group:get_players(true)
-
-]]
-function PermissionsGroups._prototype:get_players(online)
- local players = {}
- local group = self:get_raw()
- if group then
- if online == nil then
- return group.players
- else
- for _, player in pairs(group.players) do
- if player.connected == online then
- table.insert(player, player)
- end
- end
- end
- end
- return players
-end
-
---[[-- Prints a message to every player in this group
-@tparam string message the message that you want to send to the players
-@treturn number the number of players that received the message
-
-@usage-- Print a message to all players in thie group
-group:print('Hello, World!')
-
-]]
-function PermissionsGroups._prototype:print(message)
- local players = self:get_players(true)
- for _, player in pairs(players) do
- player.print(message)
- end
-
- return #players
-end
-
--- when the game starts it will make the permission groups
-Event.on_init(function()
- PermissionsGroups.reload_permissions()
-end)
-
-return PermissionsGroups
diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg
index 708296e0..58870d42 100644
--- a/exp_legacy/module/locale/en.cfg
+++ b/exp_legacy/module/locale/en.cfg
@@ -158,37 +158,6 @@ ban-player=Ban player
afk-time=__1__% of total map time\nLast moved __2__ ago
open-map=__1__ __2__\n__3__\nClick to open map
-[warp-list]
-main-caption=Warp List [img=info]
-main-tooltip=Warp List
-sub-tooltip=Warps can only be used every __1__ seconds and when within __2__ tiles\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__
-sub-tooltip-current= - __1__ This is your current warp point;
-sub-tooltip-connected= - __1__ You can travel to this warp point;
-sub-tooltip-different= - __1__ This warp is on a different network;
-sub-tooltip-cooldown= - __1__ You are currently on cooldown;
-sub-tooltip-not_available= - __1__ You are not in range of a warp point;
-sub-tooltip-bypass= - __1__ Your role allows you to travel here;
-too-close=Cannot create warp; too close to existing warp point: __1__
-too-close-to-water=Cannot create warp; too close to water, please move __1__ tiles away from the water body or landfill it
-too-close-to-entities=Cannot create warp; too close to other entities, please move __1__ tiles away from the entities or remove them
-last-edit=Last edited by __1__ at __2__\nClick to view on map
-add-tooltip=Add new warp
-confirm-tooltip=Save changes
-cancel-tooltip=Discard changes
-edit-tooltip=Currently being edited by: __1__
-edit-tooltip-none=Currently being edited by: Nobody
-remove-tooltip=Remove warp
-timer-tooltip=Please wait __1__ seconds before you can warp
-timer-tooltip-zero=After each warp you will need to wait __1__ seconds before you can warp again
-goto-tooltip=Go to x __1__ y __2__
-goto-bypass=Go to x __1__ y __2__, bypass mode
-goto-bypass-different-network=Go to x __1__ y __2__, bypass mode different network
-goto-same-warp=You are already on this warp
-goto-different-network=This warp is on a different network, use power poles to connect it
-goto-cooldown=You are on cooldown, wait for your cooldown to recharge
-goto-disabled=You are not on a warp point, walk to a warp point
-goto-edit=Edit warp icon
-
[bonus]
description=Get / Set the amount of bonus you receive.
arg-amount=Amount to set your bonus to, 0 will disable bonus.
@@ -215,42 +184,3 @@ display-fwrsb=Robot Storage
display-fwrsb-tooltip=Force worker robots storage bonus
display-ffrlm=Robot Lifetime
display-ffrlm-tooltip=Force following robots lifetime modifier
-
-[vlayer]
-main-tooltip=vlayer
-description-pbr=Recharge Player Battery upto a portion with vlayer
-description-vi=Vlayer Info
-pbr-not-running=vlayer need to be running
-display-item-solar=[img=entity/solar-panel] Solar Panel
-display-item-accumulator=[img=entity/accumulator] Accumulator
-display-current-production=[virtual-signal=signal-P] Current Production
-display-sustained-production=[virtual-signal=signal-S] Sustained Production
-display-max-capacity=[virtual-signal=signal-C] Battery Capacity
-display-current-capacity=[virtual-signal=signal-E] Battery Charge
-display-remaining-surface-area=[virtual-signal=signal-R] Remaining Surface
-display-item-solar-tooltip=The amount of Solar Panel
-display-item-accumulator-tooltip=The amount of Accumulator
-display-current-production-tooltip=Current Power Production
-display-sustained-production-tooltip=Sustained Power Production
-display-max-capacity-tooltip=Battery Max Capacity
-display-current-capacity-tooltip=Battery Current Charge
-display-remaining-surface-area-tooltip=Remaining Surface. Insert Landfill to increase
-steel-chest-detect=No steel chest detected
-steel-chest-empty=Chest is not emptied
-control-refresh=Refresh List
-control-see=See Interface
-control-build=Build Interface
-control-remove=Remove Interface
-result-build=built
-result-remove=removed
-result-interface-location=__1__ interface was located at __2__
-interface-result=__1__ __2__ __4__ interface at __3__
-result-unable=Unable to build __1__ interface, because of __2__
-result-multiple=Multiple steel chest detected
-result-limit=Max limit exceeded
-result-space=Not enough space
-control-type-energy=Energy
-control-type-circuit=Circuit
-control-type-storage-input=Storage Input
-control-type-storage-output=Storage Output
-power-on-space-research=You need to research [technology=__1__] at __2__ level for the space platform vlayer feature.
diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg
index 3f4027b1..c86023f1 100644
--- a/exp_legacy/module/locale/zh-CN.cfg
+++ b/exp_legacy/module/locale/zh-CN.cfg
@@ -8,17 +8,17 @@ status=伺服器狀態: __1__
github=增加功能或回報錯誤: __1__
patreon=支持我們: __1__
custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。
-read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看。
+read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看
softmod=這裹用了自設情境,是一種軟裝模組。
-redmew=
+redmew=We don't talk about redmew here; they beat us to 1000 members ;-;
lhd=列車必須是左則通行。這是本服務器長久以來的規則。
# data
[join-message]
description-add=設定 / 取得你的加入信息。
description-remove=移除你的加入信息。
-arg-message=你的加入信息。
-greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 DISCORD: __2__ [/color]
+arg-message=你的加入信息。
+greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 Discord: __2__ [/color]
message-set=你的加入信息已更新。
message-get=你的加入信息為: __1__
message-cleared=你的加入信息已清除。
@@ -127,7 +127,7 @@ Locale-tooltip=用戶介面語言
# expcore
[expcore-data]
description-preference=允許寫入資料。
-description-data=把用戶資料寫入電腦
+description-data=把用戶資料寫入電腦。
arg-option="All", "Statistics", "Settings", "Required" 之中
set-preference=你的喜好已設定為 __1__ 。在你重新加入前將不會有任何改動。
get-preference=你的喜好為 __1__ 。使用 /set-preference 來更改,使用 /save-data 來獲得一份資料副本。
@@ -158,37 +158,6 @@ ban-player=封禁用戶
afk-time=__1__% 地圖時間\n上次移動: __2__
open-map=__1__ __2__\n__3__\n按下打開地圖
-[warp-list]
-main-caption=傳送陣清單 [img=info]
-main-tooltip=傳送陣清單
-sub-tooltip=傳送陣在 __1__ 秒內只能使用一次。 亦需在 __2__ 格之內使用\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__
-sub-tooltip-current= - __1__ 你目前的傳送陣;
-sub-tooltip-connected= - __1__ 你可以到本傳送陣;
-sub-tooltip-different= - __1__ 該傳送陣未連接;
-sub-tooltip-cooldown= - __1__ 傳送冷卻中;
-sub-tooltip-not_available= - __1__ 你不在傳送陣範圍;
-sub-tooltip-bypass= - __1__ 你可以傳送到這裏;
-too-close=無法新建傳送陣; 太接近 __1__ 了
-too-close-to-water=無法新建傳送陣; 太接近水源了, 請移動 __1__ 格又或者填平該地方
-too-close-to-entities=無法新建傳送陣; 太接近其他東西, 請移動 __1__ 格又或者拆除該東西
-last-edit=最後由 __1__ 在 __2__ 更改\n按這在地圖上看
-add-tooltip=新建傳送陣
-confirm-tooltip=儲存更改
-cancel-tooltip=放棄更改
-edit-tooltip=現被 __1__ 修改中
-edit-tooltip-none=現沒有被人修改
-remove-tooltip=拆除傳送陣
-timer-tooltip=請等 __1__ 秒才再次傳送
-timer-tooltip-zero=你要等 __1__ 秒才可以再次傳送
-goto-tooltip=到 X __1__ Y __2__
-goto-bypass=到 X __1__ Y __2__, 繞行模式
-goto-bypass-different-network=到 X __1__ Y __2__, 繞行模式 (不同網絡)
-goto-same-warp=你已在該傳送陣
-goto-different-network=該傳送陣未連接, 你可使用電纜連接
-goto-cooldown=傳送冷卻中, 請等候冷卻
-goto-disabled=你不在傳送陣, 請移至任意傳送陣
-goto-edit=修改傳送陣圖案
-
[bonus]
description=取得 / 設定 Bonus 量。
arg-amount=Bonus 數量, 0 來停用。
@@ -216,45 +185,6 @@ display-fwrsb-tooltip=勢力機械人儲存
display-ffrlm=機械人生命
display-ffrlm-tooltip=勢力戰鬥機械人生命時間
-[vlayer]
-main-tooltip=vlayer
-description-pbr=使用 Vlayer 為玩家電池充電
-description-vi=Vlayer 資訊
-pbr-not-running=vlayer 沒有運行
-display-item-solar=[img=entity/solar-panel] 太陽能板
-display-item-accumulator=[img=entity/accumulator] 蓄電池
-display-current-production=[virtual-signal=signal-P] 現時產能
-display-sustained-production=[virtual-signal=signal-S] 持續產能
-display-max-capacity=[virtual-signal=signal-C] 電池容量
-display-current-capacity=[virtual-signal=signal-E] 電池電量
-display-remaining-surface-area=[virtual-signal=signal-R] 餘下土地
-display-item-solar-tooltip=太陽能板數量
-display-item-accumulator-tooltip=蓄電池數量
-display-current-production-tooltip=現時電力產能
-display-sustained-production-tooltip=持續電力產能
-display-max-capacity-tooltip=電池最大容量
-display-current-capacity-tooltip=現時電池電量
-display-remaining-surface-area-tooltip=餘下土地。 放入堆填來增加
-steel-chest-detect=沒檢測到鋼箱
-steel-chest-empty=鋼箱不是空的
-control-refresh=刷新列表
-control-see=查看介面
-control-build=建造介面
-control-remove=拆除介面
-result-build=建造了
-result-remove=拆除了
-result-interface-location=__1__ 介面在 __2__
-interface-result=__1__ 在 __2__ __3__ __4__ 介面
-result-unable=無法建造 __1__ 介面,因 __2__
-result-multiple=檢測到多個鋼箱
-result-limit=超出最大限制
-result-space=空間不足
-control-type-energy=電力
-control-type-circuit=回路
-control-type-storage-input=放入箱
-control-type-storage-output=提取箱
-power-on-space-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太空平台 vlayer 功能。
-
[toolbar]
main-caption=工具箱
main-tooltip=工具箱設定\n選上來設定喜好
diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg
index 3f4027b1..c86023f1 100644
--- a/exp_legacy/module/locale/zh-TW.cfg
+++ b/exp_legacy/module/locale/zh-TW.cfg
@@ -8,17 +8,17 @@ status=伺服器狀態: __1__
github=增加功能或回報錯誤: __1__
patreon=支持我們: __1__
custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。
-read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看。
+read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看
softmod=這裹用了自設情境,是一種軟裝模組。
-redmew=
+redmew=We don't talk about redmew here; they beat us to 1000 members ;-;
lhd=列車必須是左則通行。這是本服務器長久以來的規則。
# data
[join-message]
description-add=設定 / 取得你的加入信息。
description-remove=移除你的加入信息。
-arg-message=你的加入信息。
-greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 DISCORD: __2__ [/color]
+arg-message=你的加入信息。
+greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 Discord: __2__ [/color]
message-set=你的加入信息已更新。
message-get=你的加入信息為: __1__
message-cleared=你的加入信息已清除。
@@ -127,7 +127,7 @@ Locale-tooltip=用戶介面語言
# expcore
[expcore-data]
description-preference=允許寫入資料。
-description-data=把用戶資料寫入電腦
+description-data=把用戶資料寫入電腦。
arg-option="All", "Statistics", "Settings", "Required" 之中
set-preference=你的喜好已設定為 __1__ 。在你重新加入前將不會有任何改動。
get-preference=你的喜好為 __1__ 。使用 /set-preference 來更改,使用 /save-data 來獲得一份資料副本。
@@ -158,37 +158,6 @@ ban-player=封禁用戶
afk-time=__1__% 地圖時間\n上次移動: __2__
open-map=__1__ __2__\n__3__\n按下打開地圖
-[warp-list]
-main-caption=傳送陣清單 [img=info]
-main-tooltip=傳送陣清單
-sub-tooltip=傳送陣在 __1__ 秒內只能使用一次。 亦需在 __2__ 格之內使用\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__
-sub-tooltip-current= - __1__ 你目前的傳送陣;
-sub-tooltip-connected= - __1__ 你可以到本傳送陣;
-sub-tooltip-different= - __1__ 該傳送陣未連接;
-sub-tooltip-cooldown= - __1__ 傳送冷卻中;
-sub-tooltip-not_available= - __1__ 你不在傳送陣範圍;
-sub-tooltip-bypass= - __1__ 你可以傳送到這裏;
-too-close=無法新建傳送陣; 太接近 __1__ 了
-too-close-to-water=無法新建傳送陣; 太接近水源了, 請移動 __1__ 格又或者填平該地方
-too-close-to-entities=無法新建傳送陣; 太接近其他東西, 請移動 __1__ 格又或者拆除該東西
-last-edit=最後由 __1__ 在 __2__ 更改\n按這在地圖上看
-add-tooltip=新建傳送陣
-confirm-tooltip=儲存更改
-cancel-tooltip=放棄更改
-edit-tooltip=現被 __1__ 修改中
-edit-tooltip-none=現沒有被人修改
-remove-tooltip=拆除傳送陣
-timer-tooltip=請等 __1__ 秒才再次傳送
-timer-tooltip-zero=你要等 __1__ 秒才可以再次傳送
-goto-tooltip=到 X __1__ Y __2__
-goto-bypass=到 X __1__ Y __2__, 繞行模式
-goto-bypass-different-network=到 X __1__ Y __2__, 繞行模式 (不同網絡)
-goto-same-warp=你已在該傳送陣
-goto-different-network=該傳送陣未連接, 你可使用電纜連接
-goto-cooldown=傳送冷卻中, 請等候冷卻
-goto-disabled=你不在傳送陣, 請移至任意傳送陣
-goto-edit=修改傳送陣圖案
-
[bonus]
description=取得 / 設定 Bonus 量。
arg-amount=Bonus 數量, 0 來停用。
@@ -216,45 +185,6 @@ display-fwrsb-tooltip=勢力機械人儲存
display-ffrlm=機械人生命
display-ffrlm-tooltip=勢力戰鬥機械人生命時間
-[vlayer]
-main-tooltip=vlayer
-description-pbr=使用 Vlayer 為玩家電池充電
-description-vi=Vlayer 資訊
-pbr-not-running=vlayer 沒有運行
-display-item-solar=[img=entity/solar-panel] 太陽能板
-display-item-accumulator=[img=entity/accumulator] 蓄電池
-display-current-production=[virtual-signal=signal-P] 現時產能
-display-sustained-production=[virtual-signal=signal-S] 持續產能
-display-max-capacity=[virtual-signal=signal-C] 電池容量
-display-current-capacity=[virtual-signal=signal-E] 電池電量
-display-remaining-surface-area=[virtual-signal=signal-R] 餘下土地
-display-item-solar-tooltip=太陽能板數量
-display-item-accumulator-tooltip=蓄電池數量
-display-current-production-tooltip=現時電力產能
-display-sustained-production-tooltip=持續電力產能
-display-max-capacity-tooltip=電池最大容量
-display-current-capacity-tooltip=現時電池電量
-display-remaining-surface-area-tooltip=餘下土地。 放入堆填來增加
-steel-chest-detect=沒檢測到鋼箱
-steel-chest-empty=鋼箱不是空的
-control-refresh=刷新列表
-control-see=查看介面
-control-build=建造介面
-control-remove=拆除介面
-result-build=建造了
-result-remove=拆除了
-result-interface-location=__1__ 介面在 __2__
-interface-result=__1__ 在 __2__ __3__ __4__ 介面
-result-unable=無法建造 __1__ 介面,因 __2__
-result-multiple=檢測到多個鋼箱
-result-limit=超出最大限制
-result-space=空間不足
-control-type-energy=電力
-control-type-circuit=回路
-control-type-storage-input=放入箱
-control-type-storage-output=提取箱
-power-on-space-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太空平台 vlayer 功能。
-
[toolbar]
main-caption=工具箱
main-tooltip=工具箱設定\n選上來設定喜好
diff --git a/exp_legacy/module/modules/control/vlayer.lua b/exp_legacy/module/modules/control/vlayer.lua
deleted file mode 100644
index 72afba6c..00000000
--- a/exp_legacy/module/modules/control/vlayer.lua
+++ /dev/null
@@ -1,807 +0,0 @@
---[[-- Control Module - vlayer
- - Adds a virtual layer to store power to save space.
- @control vlayer
- @alias vlayer
-]]
-
-local ExpUtil = require("modules/exp_util")
-local Storage = require("modules/exp_util/storage")
-local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event
-local config = require("modules.exp_legacy.config.vlayer") --- @dep config.vlayer
-
-local mega = 1000000
-
-local vlayer = {}
-local vlayer_data = {
- entity_interfaces = {
- energy = {}, --- @type LuaEntity[]
- circuit = {}, --- @type LuaEntity[]
- storage_input = {}, --- @type LuaEntity[]
- storage_output = {}, --- @type LuaEntity[]
- },
- properties = {
- total_surface_area = 0,
- used_surface_area = 0,
- total_production = 0,
- production = 0,
- discharge = 0,
- capacity = 0,
- },
- storage = {
- items = {}, --- @type table
- power_items = {}, --- @type table
- energy = 0,
- unallocated = {}, --- @type table
- },
- surface = table.deep_copy(config.surface),
-}
-
-Storage.register(vlayer_data, function(tbl)
- vlayer_data = tbl
-end)
-
-for name, properties in pairs(config.allowed_items) do
- properties.modded = false
-
- if properties.power then
- vlayer_data.storage.power_items[name] = {
- value = assert(properties.fuel_value) * 1000000,
- count = 0,
- }
- end
-end
-
--- For all modded items, create a config for them
-for item_name, properties in pairs(config.modded_items) do
- local base_properties = config.allowed_items[properties.base_game_equivalent]
- local m = properties.multiplier
-
- config.allowed_items[item_name] = {
- starting_value = properties.starting_value or 0,
- required_area = base_properties.required_area or 0,
- surface_area = (base_properties.surface_area or 0) * m,
- production = (base_properties.production or 0) * m,
- capacity = (base_properties.capacity or 0) * m,
- modded = true,
- }
-end
-
---- Get all items in storage, do not modify
--- @treturn table a dictionary of all items stored in the vlayer
-function vlayer.get_items()
- return vlayer_data.storage.items
-end
-
---- Get all unallocated items in storage
--- @treturn table a dictionary of all unallocated items stored in the vlayer
-function vlayer.get_unallocated_items()
- return vlayer_data.storage.unallocated
-end
-
---- Get all allocated items in storage
--- @treturn table a dictionary of all allocated items stored in the vlayer
-function vlayer.get_allocated_items()
- local r = {}
-
- for k, v in pairs(vlayer_data.storage.items) do
- r[k] = v
-
- if vlayer_data.storage.unallocated[k] then
- r[k] = r[k] - vlayer_data.storage.unallocated[k]
- end
- end
-
- return r
-end
-
---- Get the actual defecit of land
-local function get_actual_land_defecit()
- local n = vlayer_data.properties.total_surface_area - vlayer_data.properties.used_surface_area
-
- for k, v in pairs(vlayer.get_unallocated_items()) do
- n = n - (config.allowed_items[k].required_area * v)
- end
-
- return n
-end
-
---- Get interface counts
--- @treturn table a dictionary of the vlayer interface counts
-function vlayer.get_interface_counts()
- local interfaces = vlayer_data.entity_interfaces
-
- return {
- energy = #interfaces.energy,
- circuit = #interfaces.circuit,
- storage_input = #interfaces.storage_input,
- storage_output = #interfaces.storage_output,
- }
-end
-
---- Get interfaces
--- @treturn table a dictionary of the vlayer interfaces
-function vlayer.get_interfaces()
- local interfaces = vlayer_data.entity_interfaces
-
- return {
- energy = interfaces.energy,
- circuit = interfaces.circuit,
- storage_input = interfaces.storage_input,
- storage_output = interfaces.storage_output,
- }
-end
-
---[[
- 25,200 / 420 s
- 昼 210秒 ソーラー効率100%
- 夕方 84秒 1秒ごとにソーラー発電量が約1.2%ずつ下がり、やがて0%になる
- 夜 42秒 ソーラー発電量が0%になる
- 朝方 84秒 1秒ごとにソーラー発電量が約1.2%ずつ上がり、やがて100%になる
-
- (surface.dawn) 0.75 18,900 Day 12,600 210s
- 0.00 0 Noon
- (surface.dusk) 0.25 6,300 Sunset 5,040 84s
- (surface.evening) 0.45 11,340 Night 2,520 42s
- (surface.morning) 0.55 13,860 Sunrise 5,040 84s
-]]
-
---- Get the power multiplier based on the surface time
-local function get_production_multiplier()
- local mul = vlayer_data.surface.solar_power_multiplier
- local surface = vlayer_data.surface
-
- if surface.always_day then
- -- Surface is always day, so full production is used
- return mul
- end
-
- if surface.darkness then
- -- We are using a real surface, our config does not contain "darkness"
- local brightness = 1 - surface.darkness
-
- if brightness >= surface.min_brightness then
- return mul * (brightness - surface.min_brightness) / (1 - surface.min_brightness)
- else
- return 0
- end
- end
-
- -- Caused by using a set config rather than a surface
- local tick = game.tick % surface.ticks_per_day
- local daytime = tick / surface.ticks_per_day
- surface.daytime = daytime
-
- if daytime <= surface.dusk then -- Noon to Sunset
- return mul
- elseif daytime <= surface.evening then -- Sunset to Night
- return mul * (1 - ((daytime - surface.dusk) / (surface.evening - surface.dusk)))
- elseif daytime <= surface.morning then -- Night to Sunrise
- return 0
- elseif daytime <= surface.dawn then -- Sunrise to Morning
- return mul * ((surface.daytime - surface.morning) / (surface.dawn - surface.morning))
- else -- Morning to Noon
- return mul
- end
-end
-
---- Get the sustained power multiplier, this needs improving
-local function get_sustained_multiplier()
- local mul = vlayer_data.surface.solar_power_multiplier
- local surface = vlayer_data.surface
-
- if surface.always_day then
- -- Surface is always day, so full production is used
- return mul
- end
-
- -- For nauvis vanilla: 210s + (1/2 x (84s + 84s))
- local day_duration = 1 - surface.dawn + surface.dusk
- local sunset_duration = surface.evening - surface.dusk
- local sunrise_duration = surface.dawn - surface.morning
-
- return mul * (day_duration + (0.5 * (sunset_duration + sunrise_duration)))
-end
-
---- Internal, Allocate items in the vlayer, this will increase the property values of the vlayer such as production and capacity
--- Does not increment item storage, so should not be called before insert_item unless during init
--- Does not validate area requirements, so checks must be performed before calling this function
--- Accepts negative count for deallocating items
--- @tparam string item_name The name of the item to allocate
--- @tparam number count The count of the item to allocate
-function vlayer.allocate_item(item_name, count)
- local item_properties = config.allowed_items[item_name]
- assert(item_properties, "Item not allowed in vlayer: " .. tostring(item_name))
-
- if item_properties.production then
- local nc = item_properties.production * count
- vlayer_data.properties.production = vlayer_data.properties.production + nc
- vlayer_data.properties.total_production = vlayer_data.properties.total_production + nc
- end
-
- if item_properties.capacity then
- vlayer_data.properties.capacity = vlayer_data.properties.capacity + item_properties.capacity * count
- end
-
- if item_properties.discharge then
- vlayer_data.properties.discharge = vlayer_data.properties.discharge + item_properties.discharge * count
- end
-
- if item_properties.surface_area then
- vlayer_data.properties.total_surface_area = vlayer_data.properties.total_surface_area + item_properties.surface_area * count
- end
-
- if item_properties.required_area and item_properties.required_area > 0 then
- vlayer_data.properties.used_surface_area = vlayer_data.properties.used_surface_area + item_properties.required_area * count
- end
-end
-
-function vlayer.unable_alloc_item_pwr_calc(item_name, count)
- local item_properties = config.allowed_items[item_name]
-
- if item_properties.production then
- vlayer_data.properties.total_production = vlayer_data.properties.total_production + item_properties.production * count
- end
-end
-
--- For all allowed items, setup their starting values, default 0
-for item_name, properties in pairs(config.allowed_items) do
- vlayer_data.storage.items[item_name] = properties.starting_value or 0
-
- if properties.required_area and properties.required_area > 0 then
- vlayer_data.storage.unallocated[item_name] = 0
- end
-
- vlayer.allocate_item(item_name, properties.starting_value)
-end
-
---- Insert an item into the vlayer, this will increment its count in storage and allocate it if possible
--- @tparam string item_name The name of the item to insert
--- @tparam number count The count of the item to insert
-function vlayer.insert_item(item_name, count)
- local item_properties = config.allowed_items[item_name]
- assert(item_properties, "Item not allowed in vlayer: " .. tostring(item_name))
- vlayer_data.storage.items[item_name] = vlayer_data.storage.items[item_name] + count
-
- if not config.unlimited_surface_area and item_properties.required_area and item_properties.required_area > 0 then
- -- Calculate how many can be allocated
- local surplus_area = vlayer_data.properties.total_surface_area - vlayer_data.properties.used_surface_area
- local allocate_count = math.min(count, math.floor(surplus_area / item_properties.required_area))
-
- if allocate_count > 0 then
- vlayer.allocate_item(item_name, allocate_count)
- end
-
- local unallocated = count - allocate_count
- vlayer_data.storage.unallocated[item_name] = vlayer_data.storage.unallocated[item_name] + unallocated
- vlayer.unable_alloc_item_pwr_calc(item_name, unallocated)
- else
- vlayer.allocate_item(item_name, count)
- end
-end
-
---- Remove an item from the vlayer, this will decrement its count in storage and prioritise unallocated items over deallocation
--- Can not always fulfil the remove request for items which provide surface area, therefore returns the amount actually removed
--- @tparam string item_name The name of the item to remove
--- @tparam number count The count of the item to remove
--- @treturn number The count of the item actually removed
-function vlayer.remove_item(item_name, count)
- local item_properties = config.allowed_items[item_name]
- assert(item_properties, "Item not allowed in vlayer: " .. tostring(item_name))
- local remove_unallocated = 0
-
- if not config.unlimited_surface_area and item_properties.required_area and item_properties.required_area > 0 then
- -- Remove from the unallocated storage first
- remove_unallocated = math.min(count, vlayer_data.storage.unallocated[item_name])
-
- if remove_unallocated > 0 then
- vlayer_data.storage.items[item_name] = vlayer_data.storage.items[item_name] - count
- vlayer_data.storage.unallocated[item_name] = vlayer_data.storage.unallocated[item_name] - count
- end
-
- -- Check if any more items need to be removed
- count = count - remove_unallocated
-
- if count == 0 then
- return remove_unallocated
- end
- end
-
- -- Calculate the amount to remove based on items in storage
- local remove_count = math.min(count, vlayer_data.storage.items[item_name])
-
- if item_properties.surface_area and item_properties.surface_area > 0 then
- -- If the item provides surface area then it has additional limitations
- local surplus_area = vlayer_data.properties.total_surface_area - vlayer_data.properties.used_surface_area
- remove_count = math.min(remove_count, math.floor(surplus_area / item_properties.surface_area))
-
- if remove_count <= 0 then
- return remove_unallocated
- end
- end
-
- -- Remove the item from allocated storage
- vlayer_data.storage.items[item_name] = vlayer_data.storage.items[item_name] - remove_count
- vlayer.allocate_item(item_name, -remove_count)
-
- return remove_unallocated + remove_count
-end
-
---- Create a new storage input interface
--- @tparam LuaSurface surface The surface to place the interface onto
--- @tparam MapPosition position The position on the surface to place the interface at
--- @tparam[opt] LuaPlayer player The player to show as the last user of the interface
--- @treturn LuaEntity The entity that was created for the interface
-function vlayer.create_input_interface(surface, position, circuit, last_user)
- local interface = surface.create_entity{ name = "storage-chest", position = position, force = "neutral" }
- interface.storage_filter = { name = "deconstruction-planner", quality = "normal" }
- table.insert(vlayer_data.entity_interfaces.storage_input, interface)
-
- if last_user then
- interface.last_user = last_user
- end
-
- if circuit then
- for k, _ in pairs(circuit) do
- for _, v in pairs(circuit[k]) do
- interface.connect_neighbour{ wire = defines.wire_type[k], target_entity = v }
- end
- end
- end
-
- interface.destructible = false
- interface.minable_flag = false
- interface.operable = true
-
- return interface
-end
-
---- Handle all input interfaces, will take their contents and insert it into the vlayer storage
-local function handle_input_interfaces()
- for index, interface in pairs(vlayer_data.entity_interfaces.storage_input) do
- if not interface.valid then
- vlayer_data.entity_interfaces.storage_input[index] = nil
- else
- local inventory = assert(interface.get_inventory(defines.inventory.chest))
-
- for _, v in pairs(inventory.get_contents()) do
- if config.allowed_items[v.name] then
- --[[
- there are no quality support currently.
- so instead, using the stats projection value for higher quality.
- ]]
- local count_deduct
- local count_add
-
- if prototypes.quality[v.quality].level == 0 then
- count_deduct = v.count
- count_add = v.count
-
- elseif prototypes.quality[v.quality].level > 0 and v.count >= 10 then
- local batch = math.floor(v.count / 10)
- count_deduct = batch * 10
- count_add = batch * (10 + (prototypes.quality[v.quality].level * 3))
- end
-
- if count_deduct and count_add then
- if config.allowed_items[v.name].modded then
- local modded_item = assert(config.modded_items[v.name])
- if config.modded_auto_downgrade then
- vlayer.insert_item(modded_item.base_game_equivalent, count_add * modded_item.multiplier)
- else
- vlayer.insert_item(v.name, count_add)
- end
- else
- if vlayer_data.storage.power_items[v.name] then
- vlayer_data.storage.power_items[v.name].count = vlayer_data.storage.power_items[v.name].count + count_add
- else
- vlayer.insert_item(v.name, count_add)
- end
- end
-
- inventory.remove{ name = v.name, count = count_deduct, quality = v.quality }
- end
- end
- end
- end
- end
-end
-
---- Create a new storage output interface
--- @tparam LuaSurface surface The surface to place the interface onto
--- @tparam MapPosition position The position on the surface to place the interface at
--- @tparam[opt] LuaPlayer player The player to show as the last user of the interface
--- @treturn LuaEntity The entity that was created for the interface
-function vlayer.create_output_interface(surface, position, circuit, last_user)
- local interface = surface.create_entity{ name = "requester-chest", position = position, force = "neutral" }
- table.insert(vlayer_data.entity_interfaces.storage_output, interface)
-
- if last_user then
- interface.last_user = last_user
- end
-
- if circuit then
- for k, _ in pairs(circuit) do
- for _, v in pairs(circuit[k]) do
- interface.connect_neighbour{ wire = defines.wire_type[k], target_entity = v }
- end
- end
- end
-
- interface.destructible = false
- interface.minable_flag = false
- interface.operable = true
-
- return interface
-end
-
---- Handle all output interfaces, will take their requests and remove it from the vlayer storage
-local function handle_output_interfaces()
- for index, interface in pairs(vlayer_data.entity_interfaces.storage_output) do
- if not interface.valid then
- vlayer_data.entity_interfaces.storage_output[index] = nil
- else
- local inventory = assert(interface.get_inventory(defines.inventory.chest))
- local inventory_request_sections = assert(interface.get_logistic_sections()).sections
-
- for i = 1, #inventory_request_sections do
- for _, v in pairs(inventory_request_sections[i].filters) do
- if v.value and config.allowed_items[v.value.name] then
- local current_amount = inventory.get_item_count(v.value.name)
- local request_amount = math.min(assert(v.min) - current_amount, vlayer_data.storage.items[v.value.name])
-
- if request_amount > 0 and inventory.can_insert{ name = v.value.name, count = request_amount } then
- local removed_item_count = vlayer.remove_item(v.value.name, request_amount)
-
- if removed_item_count > 0 then
- inventory.insert{ name = v.value.name, count = removed_item_count, quality = "normal" }
- end
- end
- end
- end
- end
- end
- end
-end
-
---- Handle the unallocated items because more surface area may have been added
-local function handle_unallocated()
- -- unallocated cant happen when its unlimited
- if config.unlimited_surface_area then
- return
- end
-
- -- Get the total unallocated area so items can be allocated in equal amounts
- local unallocated_area = 0
-
- for item_name, count in pairs(vlayer_data.storage.unallocated) do
- local item_properties = config.allowed_items[item_name]
- unallocated_area = unallocated_area + item_properties.required_area * count
- end
-
- if unallocated_area == 0 then
- return
- end
-
- -- Allocate items in an equal distribution
- local surplus_area = vlayer_data.properties.total_surface_area - vlayer_data.properties.used_surface_area
-
- for item_name, count in pairs(vlayer_data.storage.unallocated) do
- local allocation_count = math.min(count, math.floor(count * surplus_area / unallocated_area))
-
- if allocation_count > 0 then
- vlayer_data.storage.unallocated[item_name] = vlayer_data.storage.unallocated[item_name] - allocation_count
- vlayer.allocate_item(item_name, allocation_count)
- vlayer.unable_alloc_item_pwr_calc(item_name, -allocation_count)
- end
- end
-end
-
---- Get the statistics for the vlayer
-function vlayer.get_statistics()
- local vdp = vlayer_data.properties.production * mega
- local gdm = get_production_multiplier()
- local gsm = get_sustained_multiplier()
- local gald = get_actual_land_defecit()
-
- return {
- total_surface_area = vlayer_data.properties.total_surface_area,
- used_surface_area = vlayer_data.properties.used_surface_area,
- remaining_surface_area = gald,
- surface_area = vlayer_data.properties.total_surface_area - gald,
- production_multiplier = gdm,
- energy_max = vdp,
- energy_production = vdp * gdm,
- energy_total_production = vlayer_data.properties.total_production * gsm * mega,
- energy_sustained = vdp * gsm,
- energy_capacity = vlayer_data.properties.capacity * mega,
- energy_storage = vlayer_data.storage.energy,
- }
-end
-
---- add or reduce vlayer power
---- @param power number
---- @return number
-function vlayer.energy_changed(power)
- local new_value = vlayer_data.storage.energy + power
- vlayer_data.storage.energy = new_value
- return new_value
-end
-
---- Circuit signals used for the statistics
-function vlayer.get_circuits()
- return {
- total_surface_area = "signal-A",
- used_surface_area = "signal-U",
- remaining_surface_area = "signal-R",
- production_multiplier = "signal-M",
- energy_production = "signal-P",
- energy_sustained = "signal-S",
- energy_capacity = "signal-C",
- energy_storage = "signal-E",
- }
-end
-
-local vlayer_circuits_string = ""
-
-for key, value in pairs(vlayer.get_circuits()) do
- vlayer_circuits_string = vlayer_circuits_string .. string.format("[virtual-signal=%s] = %s\n", value, key:gsub("_", " "))
-end
-
---- Create a new circuit interface
--- @tparam LuaSurface surface The surface to place the interface onto
--- @tparam MapPosition position The position on the surface to place the interface at
--- @tparam[opt] LuaPlayer player The player to show as the last user of the interface
--- @treturn LuaEntity The entity that was created for the interface
-function vlayer.create_circuit_interface(surface, position, circuit, last_user)
- local interface = surface.create_entity{ name = "constant-combinator", position = position, force = "neutral" }
- table.insert(vlayer_data.entity_interfaces.circuit, interface)
-
- if last_user then
- interface.last_user = last_user
- end
-
- if circuit then
- for k, _ in pairs(circuit) do
- for _, v in pairs(circuit[k]) do
- interface.connect_neighbour{ wire = defines.wire_type[k], target_entity = v }
- end
- end
- end
-
- interface.destructible = false
- interface.minable_flag = false
- interface.operable = true
-
- return interface
-end
-
---- Handle all circuit interfaces, updating their signals to match the vlayer statistics
-local function handle_circuit_interfaces()
- local stats = vlayer.get_statistics()
-
- for index, interface in pairs(vlayer_data.entity_interfaces.circuit) do
- if not interface.valid then
- vlayer_data.entity_interfaces.circuit[index] = nil
- else
- local control = interface.get_or_create_control_behavior() --[[@as LuaConstantCombinatorControlBehavior]]
- if control.sections_count == 0 then
- control.add_section()
- end
- local circuit_oc = assert(control.sections[1])
- local signal_index = 1
- local circuit = vlayer.get_circuits()
-
- -- Set the virtual signals based on the vlayer stats
- for stat_name, signal_name in pairs(circuit) do
- if stat_name:find("energy") then
- circuit_oc.set_slot(signal_index, { value = { type = "virtual", name = signal_name, quality = "normal" }, min = math.floor(stats[stat_name] / mega) })
- elseif stat_name == "production_multiplier" then
- circuit_oc.set_slot(signal_index, { value = { type = "virtual", name = signal_name, quality = "normal" }, min = math.floor(stats[stat_name] * 10000) })
- else
- circuit_oc.set_slot(signal_index, { value = { type = "virtual", name = signal_name, quality = "normal" }, min = math.floor(stats[stat_name]) })
- end
-
- signal_index = signal_index + 1
- end
-
- -- Set the item signals based on stored items
- for item_name, count in pairs(vlayer_data.storage.items) do
- if prototypes.item[item_name] and count > 0 then
- local existing_index = circuit_oc.set_slot(signal_index, { value = { type = "item", name = item_name, quality = "normal" }, min = count })
- if existing_index then
- circuit_oc.clear_slot(existing_index)
- circuit_oc.set_slot(signal_index, { value = { type = "item", name = item_name, quality = "normal" }, min = count })
- end
- signal_index = signal_index + 1
- end
- end
-
- -- Clear remaining signals to prevent outdated values being present (caused by count > 0 check)
- for clear_index = signal_index, #circuit do
- if not circuit_oc.get_slot(clear_index).value then
- break -- There are no more signals to clear
- end
-
- circuit_oc.clear_slot(clear_index)
- end
-
- interface.combinator_description = vlayer_circuits_string
- end
- end
-end
-
---- Create a new energy interface
--- @tparam LuaSurface surface The surface to place the interface onto
--- @tparam MapPosition position The position on the surface to place the interface at
--- @tparam[opt] LuaPlayer player The player to show as the last user of the interface
--- @treturn LuaEntity The entity that was created for the interface, or nil if it could not be created
-function vlayer.create_energy_interface(surface, position, last_user)
- if not surface.can_place_entity{ name = "electric-energy-interface", position = position } then
- return nil
- end
-
- local interface = surface.create_entity{ name = "electric-energy-interface", position = position, force = "neutral" }
- table.insert(vlayer_data.entity_interfaces.energy, interface)
-
- if last_user then
- interface.last_user = last_user
- end
-
- interface.destructible = false
- interface.minable_flag = false
- interface.operable = false
- interface.electric_buffer_size = 0
- interface.power_production = 0
- interface.power_usage = 0
- interface.energy = 0
-
- return interface
-end
-
---- Handle all energy interfaces as well as the energy storage
-local function handle_energy_interfaces()
- -- Add the newly produced power
- local production = vlayer_data.properties.production * mega * (config.update_tick_energy / 60)
- vlayer_data.storage.energy = vlayer_data.storage.energy + math.floor(production * get_production_multiplier())
-
- -- Calculate how much power is present in the network, that is storage + all interfaces
- if #vlayer_data.entity_interfaces.energy > 0 then
- local available_energy = vlayer_data.storage.energy
-
- for index, interface in pairs(vlayer_data.entity_interfaces.energy) do
- if not interface.valid then
- vlayer_data.entity_interfaces.energy[index] = nil
- else
- available_energy = available_energy + interface.energy
- end
- end
-
- -- Distribute the energy between all interfaces
- local discharge_rate = 2 * (production + vlayer_data.properties.discharge * mega) / #vlayer_data.entity_interfaces.energy
- local fill_to = math.min(discharge_rate, math.floor(available_energy / #vlayer_data.entity_interfaces.energy))
-
- for _, interface in pairs(vlayer_data.entity_interfaces.energy) do
- interface.electric_buffer_size = math.max(discharge_rate, interface.energy) -- prevent energy loss
- local delta = fill_to - interface.energy -- positive means storage to interface
- vlayer_data.storage.energy = vlayer_data.storage.energy - delta
- interface.energy = interface.energy + delta
- end
- end
-
- -- Cap the stored energy to the allowed capacity
- if not config.unlimited_capacity and vlayer_data.storage.energy > vlayer_data.properties.capacity * mega then
- vlayer_data.storage.energy = vlayer_data.properties.capacity * mega
-
- -- burn the trash to produce power
- elseif vlayer_data.storage.power_items then
- for k, v in pairs(vlayer_data.storage.power_items) do
- local max_burning = (vlayer_data.properties.capacity * mega / 2) - vlayer_data.storage.energy
-
- if v.count > 0 and max_burning > 0 then
- local to_burn = math.min(v.count, math.floor(max_burning / v.value))
-
- vlayer_data.storage.energy = vlayer_data.storage.energy + (to_burn * v.value)
- vlayer_data.storage.power_items[k].count = vlayer_data.storage.power_items[k].count - to_burn
- end
- end
- end
-end
-
---- Remove the entity interface using the given position
--- @tparam LuaSurface surface The surface to search for an interface on
--- @tparam MapPosition position The position of the item
--- @treturn string The type of interface that was removed, or nil if no interface was found
--- @treturn MapPosition The position the interface was at, or nil if no interface was found
-function vlayer.remove_interface(surface, position)
- local entities = surface.find_entities_filtered{
- name = { "storage-chest", "requester-chest", "constant-combinator", "electric-energy-interface" },
- force = "neutral",
- position = position,
- radius = 2,
- limit = 1,
- }
-
- -- Get the details which will be returned
- if #entities == 0 then
- return nil, nil, nil
- end
-
- local interface = entities[1]
- local name = interface.name
- local sur = interface.surface
- local pos = interface.position
-
- -- Return the type of interface removed and do some clean up
- if name == "storage-chest" then
- local inventory = assert(interface.get_inventory(defines.inventory.chest))
- ExpUtil.transfer_inventory_to_surface{
- inventory = inventory,
- surface = interface.surface,
- name = "iron-chest",
- allow_creation = true,
- }
- table.remove_element(vlayer_data.entity_interfaces.storage_input, interface)
- interface.destroy()
-
- return "storage-input", sur, pos
- elseif name == "requester-chest" then
- local inventory = assert(interface.get_inventory(defines.inventory.chest))
- ExpUtil.transfer_inventory_to_surface{
- inventory = inventory,
- surface = interface.surface,
- name = "iron-chest",
- allow_creation = true,
- }
- table.remove_element(vlayer_data.entity_interfaces.storage_output, interface)
- interface.destroy()
-
- return "storage-output", sur, pos
- elseif name == "constant-combinator" then
- table.remove_element(vlayer_data.entity_interfaces.circuit, interface)
- interface.destroy()
-
- return "circuit", sur, pos
- elseif name == "electric-energy-interface" then
- vlayer_data.storage.energy = vlayer_data.storage.energy + interface.energy
- table.remove_element(vlayer_data.entity_interfaces.energy, interface)
- interface.destroy()
-
- return "energy", sur, pos
- end
-end
-
-local function on_surface_event()
- if config.mimic_surface then
- local surface = game.get_surface(config.mimic_surface)
-
- if surface then
- vlayer_data.surface = surface
- return
- end
- end
-
- if not vlayer_data.surface.index then
- -- Our fake surface data never has an index, we test for this to avoid unneeded copies from the config
- vlayer_data.surface = table.deep_copy(config.surface)
- end
-end
-
---- Handle all storage IO and attempt allocation of unallocated items
-Event.on_nth_tick(config.update_tick_storage, function(_)
- handle_input_interfaces()
- handle_output_interfaces()
- handle_unallocated()
-end)
-
---- Handle all energy and circuit updates
-Event.on_nth_tick(config.update_tick_energy, function(_)
- handle_circuit_interfaces()
- handle_energy_interfaces()
-end)
-
-Event.add(defines.events.on_surface_created, on_surface_event)
-Event.add(defines.events.on_surface_renamed, on_surface_event)
-Event.add(defines.events.on_surface_imported, on_surface_event)
-Event.on_init(on_surface_event) -- Default surface always exists, does not trigger on_surface_created
-
-return vlayer
diff --git a/exp_legacy/module/modules/control/warps.lua b/exp_legacy/module/modules/control/warps.lua
deleted file mode 100644
index fdca5b62..00000000
--- a/exp_legacy/module/modules/control/warps.lua
+++ /dev/null
@@ -1,488 +0,0 @@
---[[-- Control Module - Warps
-- Stores warps for each force.
-@control Warps
-@alias Warps
-
-@usage-- Making a new spawn warp
-local player = game.player
-local force = player.force
-local spawn_id = Warps.add_warp(force.name, player.physical_surface, player.physical_position, player.name, 'Spawn')
-
-Warps.set_spawn_warp(spawn_id, force)
-Warps.make_warp_tag(spawn_id)
-
-@usage-- Making a new warp with a warp area
-local player = game.player
-local force = player.force
-local warp_id = Warps.add_warp(force.name, player.physical_surface, player.physical_position, player.name)
-
-Warps.make_warp_area(warp_id)
-Warps.make_warp_tag(warp_id)
-
-]]
-
-local Datastore = require("modules.exp_legacy.expcore.datastore") --- @dep expcore.datastore
-local Storage = require("modules/exp_util/storage")
-local config = require("modules.exp_legacy.config.gui.warps") --- @dep config.warps
-
---- Stores all data for the warp system
-local WrapData = Datastore.connect("WrapData")
-WrapData:set_serializer(function(raw_key) return raw_key.warp_id end)
-
-local Warps = {}
-
---- @class ExpScenario_Warps.force_warps: { spawn: string, [number]: string }
-
--- Storage lookup table for force name to task ids
-local force_warps = { _uid = 1 }
---- @cast force_warps table
-Storage.register(force_warps, function(tbl)
- force_warps = tbl
-end)
-
--- Create an array of entity names that will be added to the remove filter
-local remove_warp_area_entity_names = {}
-for _, entity in pairs(config.entities) do
- table.insert(remove_warp_area_entity_names, entity[1])
-end
-
--- When a warp is updated change its chat tag and restore the warp order
-WrapData:on_update(function(warp_id, warp, old_warp)
- if warp then
- warp.updates = warp.updates + 1
- -- Update the map chart tag if there is one
- if warp.tag then
- Warps.make_warp_tag(warp_id)
- end
-
- -- Check that the name of the warp has been changed
- if not old_warp or warp.name == old_warp.name then return end
-
- -- Get the names of all the warp points for this force
- local force_name = warp.force_name
- local warp_ids = force_warps[force_name]
- local spawn_id = warp_ids.spawn
-
- local warp_names = {} --- @type table
- for _, next_warp_id in pairs(warp_ids) do
- local next_warp = WrapData:get(next_warp_id)
- if next_warp_id ~= spawn_id then
- warp_names[next_warp.name .. next_warp_id] = next_warp_id
- end
- end
-
- -- Sort the warp names in alphabetical order
- local new_warp_ids = table.get_values(table.key_sort(warp_names))
- --- @cast new_warp_ids ExpScenario_Warps.force_warps
- table.insert(new_warp_ids, 1, spawn_id)
- new_warp_ids.spawn = spawn_id
- force_warps[force_name] = new_warp_ids
- end
-end)
-
---- Map Integration.
--- functions used to create and alter warps with in the map
--- @section mapIntegration
-
---[[-- Add or update the chat tag for this warp
-@tparam string warp_id the uid of the warp you want the chart tag for
-@treturn boolean true if a new tag was made, false if it was updated
-
-@usage-- Adding a chart tag for a new warp
-local tag_added = Warps.make_warp_tag(warp_id)
-
-]]
-function Warps.make_warp_tag(warp_id)
- local warp = WrapData:get(warp_id)
- local name = warp.name
- local icon = warp.icon
-
- -- Edit the existing tag if it is present
- local tag = warp.tag
-
- if tag and tag.valid then
- tag.text = "Warp: " .. name
- tag.icon = icon
- return false
- end
-
- -- Make a new tag if one did not exist
- local force = game.forces[warp.force_name]
- local surface = warp.surface
- local position = warp.position
-
- tag = force.add_chart_tag(surface, {
- position = { position.x + 0.5, position.y + 0.5 },
- text = "Warp: " .. name,
- icon = icon,
- })
-
- -- Add the tag to this warp, store.update not needed as we dont want it to trigger
- warp.tag = tag
- return true
-end
-
---[[-- Remove the chart tag for this warp
-@tparam string warp_id the uid for the warp that you want to remove the chart tag from
-@treturn boolean true if the tag was valid and was removed, false if the tag was invalid
-
-@usage-- Removing the chart tag from a warp
-local removed = Warps.remove_warp_tag(warp_id)
-
-]]
-function Warps.remove_warp_tag(warp_id)
- local warp = WrapData:get(warp_id)
-
- -- Check there is a tag to remove
- local tag = warp.tag
- if not tag or not tag.valid then
- warp.tag = nil
- return false
- end
-
- -- Remove the warp chart tag if it is valid
- tag.destroy()
- warp.tag = nil
-
- return true
-end
-
---[[-- Add a warp area for the warp, purely cosmetic
-@tparam string warp_id the uid of the warp you want the area for
-
-@usage-- Adding a warp area for a warp
-Warps.make_warp_area(warp_id)
-
-]]
-function Warps.make_warp_area(warp_id)
- local warp = WrapData:get(warp_id)
- local surface = warp.surface
- local position = warp.position
- local posx = position.x
- local posy = position.y
-
- -- Get the tile that is being replaced, store.update not needed as we don't want it to trigger
- local old_tile = surface.get_tile(position).name
- warp.old_tile = old_tile
-
- -- Add a tile pattern on top of the base
- local tiles = {}
- for _, tile in pairs(config.tiles) do
- table.insert(tiles, { name = tile[1], position = { tile[2] + posx, tile[3] + posy } })
- end
-
- surface.set_tiles(tiles)
-
- -- Add entities to the warp structure
- for _, entity in pairs(config.entities) do
- entity = surface.create_entity{
- name = entity[1],
- position = { entity[2] + posx, entity[3] + posy },
- force = "neutral",
- }
- entity.destructible = false
- entity.health = 0
- entity.minable_flag = false
- entity.rotatable = false
-
- -- Save reference of the last power pole
- if entity.type == "electric-pole" then
- warp.electric_pole = entity
- end
- end
-end
-
---[[-- Remove the warp area for a warp
-@tparam string warp_id the uid of the warp that you want to remove the area for
-
-@usage-- Remove the warp area for a warp
-Warps.remove_warp_area(warp_id)
-
-]]
-function Warps.remove_warp_area(warp_id)
- local warp = WrapData:get(warp_id)
- local position = warp.position
- local surface = warp.surface
- local radius = config.standard_proximity_radius
-
- -- Check that a warp area was created previously
- local old_tile = warp.old_tile
- if not old_tile then return end
-
- -- Restore the original tiles before the creation of the warp
- local tiles = {}
-
- for _, tile in pairs(config.tiles) do
- table.insert(tiles, { name = old_tile, position = { tile[2] + position.x, tile[3] + position.y } })
- end
-
- surface.set_tiles(tiles)
-
- local area = {
- { position.x - radius, position.y - radius },
- { position.x + radius, position.y + radius },
- }
-
- -- Remove warp structure entities
- local entities = surface.find_entities_filtered{ force = "neutral", area = area, name = remove_warp_area_entity_names }
- for _, entity in pairs(entities) do
- -- Destroy them, this will leave corpses of the entities that it destroyed.
- if entity and entity.valid and entity.destructible == false then
- entity.destructible = true
- entity.die(entity.force)
- end
- end
-
- -- Rechart map area, useful if warp is not covered by a radar
- game.forces[warp.force_name].chart(surface, area)
-end
-
---[[-- Set a warp to be the spawn point for a force, force must own this warp
-@tparam string warp_id the uid of the warp that you want to be the spawn for the force
-@tparam LuaForce force the force that you want to set the spawn for
-
-@usage-- Set your forces spawn to a warp
-Warps.set_spawn_warp(warp_id, game.player.force)
-
-]]
-function Warps.set_spawn_warp(warp_id, force)
- -- Check the force owns this warp
- local warp = WrapData:get(warp_id)
- if warp.force_name ~= force.name then
- return
- end
- -- Set this warp as the spawn
- local warp_ids = force_warps[warp.force_name]
- if not warp_ids then
- warp_ids = {}
- force_warps[warp.force_name] = warp_ids
- end
- warp_ids.spawn = warp_id
- -- Set the forces spawn to this warp
- force.set_spawn_position(warp.position, warp.surface)
-end
-
---[[-- Teleport a player to a warp point
-@tparam string warp_id the uid of the warp to send the player to
-@tparam LuaPlayer player the player to teleport to the warp
-
-@usage-- Teleport yourself to a warp point
-Warps.teleport_player(warp_id, game.player)
-
-]]
-function Warps.teleport_player(warp_id, player)
- local warp = WrapData:get(warp_id)
- local surface = warp.surface
- local position = {
- x = warp.position.x + 0.5,
- y = warp.position.y + 0.5,
- }
-
- if player.vehicle then
- -- Teleport the entity
- local entity = player.vehicle
- local goto_position = surface.find_non_colliding_position(entity.name, position, 32, 1)
- -- Surface teleport can only be done for players and cars at the moment. (with surface as an peramitor it gives this error)
- if entity.type == "car" then
- entity.teleport(goto_position, surface)
- elseif surface.index == entity.surface.index then
- -- Try teleport the entity
- if not entity.teleport(goto_position) then
- player.driving = false
- -- Need to calculate new goto_position because entities have different collision boxes
- goto_position = surface.find_non_colliding_position("character", position, 32, 1)
- player.teleport(goto_position, surface)
- end
- end
- else
- -- Teleport the player
- local goto_position = surface.find_non_colliding_position("character", position, 32, 1)
- if player.driving then player.driving = false end
- player.teleport(goto_position, surface)
- end
-end
-
---- Setters.
--- functions used to created and alter warps
--- @section setters
-
---[[-- Add a new warp for a force, the warp must have a surface and a position
-@tparam string force_name the name of the force to add the warp for
-@tparam LuaSurface surface the surface that the warp will be on
-@tparam Concepts.Position position the position that the warp will be on
-@tparam[opt] string player_name the name of the player that is adding the warp
-@tparam[opt] string warp_name the name of the warp that is being added, if omited default is used
-@treturn string the uid of the warp which was created
-
-@usage-- Adding a new warp for your force at your position
-local player = game.player
-local warp_id = Warps.add_warp(player.force.name, player.physical_surface, player.physical_position, player.name)
-
-]]
-function Warps.add_warp(force_name, surface, position, player_name, warp_name)
- -- Get new warp id
- local warp_id = tostring(force_warps._uid)
- force_warps._uid = force_warps._uid + 1
- warp_name = warp_name or "New warp"
-
- -- Get the existing warps for this force
- local warp_ids = force_warps[force_name]
- if not warp_ids then
- warp_ids = {}
- force_warps[force_name] = warp_ids
- end
-
- -- Insert the warp id into the force warps
- table.insert(warp_ids, warp_id)
-
- -- Create the editing table
- local editing = {}
- if player_name then
- editing[player_name] = true
- end
-
- -- Add the new warp to the store
- WrapData:set(warp_id, {
- warp_id = warp_id,
- force_name = force_name,
- name = warp_name,
- icon = { type = config.default_icon.type, name = config.default_icon.name },
- surface = surface,
- position = {
- x = math.floor(position.x),
- y = math.floor(position.y),
- },
- last_edit_name = player_name or "",
- last_edit_time = game.tick,
- currently_editing = editing,
- updates = 0,
- })
-
- return warp_id
-end
-
---[[-- Removes a warp and any data linked to it
-@tparam string warp_id the uid of the warp that you want to remove
-
-@usage-- Removing a warp
-Warps.remove_warp(warp_id)
-
-]]
-function Warps.remove_warp(warp_id)
- local warp = WrapData:get(warp_id)
- local force_name = warp.force_name
- Warps.remove_warp_tag(warp_id)
- Warps.remove_warp_area(warp_id)
- WrapData:remove(warp_id)
- table.remove_element(force_warps[force_name], warp_id)
-end
-
---[[-- Update the name and icon for a warp
-@tparam string warp_id the uid of the warp that you want to update
-@tparam[opt] string new_name the new name that you want the warp to have
-@tparam[opt] string new_icon the new icon that you want the warp to have
-@tparam[opt='server'] string player_name the name of the player that made the edit
-
-@usage-- Changing the name and icon for a warp
-Warps.update_warp(warp_id, 'My Warp', 'iron-plate', game.player.name)
-
-]]
-function Warps.update_warp(warp_id, new_name, new_icon, player_name)
- WrapData:update(warp_id, function(_, warp)
- -- If the icon is not valid then replace with the old icon
- if new_icon and not new_icon.name or not new_icon.type then
- new_icon = warp.icon
- end
-
- warp.last_edit_name = player_name or ""
- warp.last_edit_time = game.tick
- warp.name = new_name or warp.name
- warp.icon = new_icon or warp.icon
- end)
-end
-
---[[-- Set the editing state for a player, can be used as a warning or to display a text field
-@tparam string warp_id the uid of the warp that you want to effect
-@tparam string player_name the name of the player you want to set the state for
-@tparam boolean state the new state to set editing to
-
-@usage-- Setting your editing state to true
-Warps.set_editing(warp_id, game.player.name, true)
-
-]]
-function Warps.set_editing(warp_id, player_name, state)
- WrapData:update(warp_id, function(_, warp)
- warp.currently_editing[player_name] = state
- end)
-end
-
---[[-- Adds an update handler for when a warp is added, removed, or updated
-@tparam function handler the handler which is called when a warp is updated
-
-@usage-- Add a game print when a warp is updated
-Warps.on_update(function(warp)
- game.print(warp.force_name..' now has the warp: '..warp.name)
-end)
-
-]]
-function Warps.on_update(handler)
- WrapData:on_update(handler)
-end
-
---- Getters.
--- function used to get information about warps
--- @section getters
-
---[[-- Gets the warp information that is linked with this id
-@tparam string warp_id the uid of the warp you want to get
-@treturn table the warp information
-
-@usage-- Getting warp information outside of on_update
-local warp = Warps.get_warp(warp_id)
-
-]]
-function Warps.get_warp(warp_id)
- return WrapData:get(warp_id)
-end
-
---[[-- Gets all the warp ids that a force has
-@tparam string force_name the name of the force that you want the warp ids for
-@treturn table an array of all the warp ids
-
-@usage-- Getting the warp ids for a force
-local warp_ids = Warps.get_force_warp_ids(game.player.force.name)
-
-]]
-function Warps.get_force_warp_ids(force_name)
- return force_warps[force_name] or {}
-end
-
---[[-- Get the id of the spawn warp
-@tparam string force_name the name of the force that you want to get the spawn warp for
-@treturn ?string|nil the uid of the spawn warp for this force if there is one
-
-@usage-- Getting the spawn warp id
-local spawn_id = Warps.get_spawn_warp_id(game.player.force.name)
-
-]]
-function Warps.get_spawn_warp_id(force_name)
- local warp_ids = force_warps[force_name] or {}
- return warp_ids.spawn
-end
-
---[[-- Gets the editing state for a player
-@tparam string warp_id the uid of the warp you want to check
-@tparam string player_name the name of the player that you want to check
-@treturn boolean weather the player is currently editing this warp
-
-@usage-- Check if a player is editing a warp or not
-local editing = Warps.get_editing(warp_id, game.player.name)
-
-]]
-function Warps.get_editing(warp_id, player_name)
- local warp = WrapData:get(warp_id)
- return warp.currently_editing[player_name]
-end
-
--- Module return
-return Warps
diff --git a/exp_legacy/module/modules/gui/vlayer.lua b/exp_legacy/module/modules/gui/vlayer.lua
deleted file mode 100644
index 1060b786..00000000
--- a/exp_legacy/module/modules/gui/vlayer.lua
+++ /dev/null
@@ -1,547 +0,0 @@
---[[-- Gui Module - Virtual Layer
- - Adds a virtual layer to store power to save space.
- @gui Virtual Layer
- @alias vlayer_container
-]]
-
-local Gui = require("modules/exp_gui")
-local Roles = require("modules/exp_roles")
-local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event
-local format_number = require("util").format_number --- @dep util
-local config = require("modules.exp_legacy.config.vlayer") --- @dep config.vlayer
-local vlayer = require("modules.exp_legacy.modules.control.vlayer")
-
-local Selection = require("modules/exp_util/selection")
-local SelectArea = Selection.connect("ExpGui_VLayer")
-
---- Align an aabb to the grid by expanding it
-local function aabb_align_expand(aabb)
- return {
- left_top = { x = math.floor(aabb.left_top.x), y = math.floor(aabb.left_top.y) },
- right_bottom = { x = math.ceil(aabb.right_bottom.x), y = math.ceil(aabb.right_bottom.y) },
- }
-end
-
-local vlayer_container --- @type ExpElement
-local vlayer_gui_control_type --- @type ExpElement
-local vlayer_gui_control_list --- @type ExpElement
-
-local vlayer_control_type_list = {
- [1] = "energy",
- [2] = "circuit",
- [3] = "storage_input",
- [4] = "storage_output",
-}
-
-local function pos_to_gps_string(pos, surface_name)
- return "[gps=" .. string.format("%.1f", pos.x) .. "," .. string.format("%.1f", pos.y) .. "," .. surface_name .. "]"
-end
-
-local function format_energy(amount, unit)
- if amount < 1 then
- return "0 " .. unit
- end
-
- local suffix = ""
- local suffix_list = {
- ["P"] = 1000000000000000,
- ["T"] = 1000000000000,
- ["G"] = 1000000000,
- ["M"] = 1000000,
- ["k"] = 1000,
- }
-
- for letter, limit in pairs(suffix_list) do
- if math.abs(amount) >= limit then
- amount = string.format("%.1f", amount / limit)
- suffix = letter
- break
- end
- end
-
- local k
- local formatted = amount
-
- while true do
- formatted, k = string.gsub(formatted, "^(-?%d+)(%d%d%d)", "%1,%2")
-
- if (k == 0) then
- break
- end
- end
-
- return formatted .. " " .. suffix .. unit
-end
-
-local ExpUtil = require("modules/exp_util")
---- When an area is selected to add protection to the area
-SelectArea:on_selection(function(event)
- log(ExpUtil.format_any(event))
- local area = aabb_align_expand(event.area)
- local player = game.players[event.player_index]
-
- if not player then
- return
- end
-
- local container = Gui.get_left_element(vlayer_container, player)
- local disp = container.frame["vlayer_st_2"].disp.table
- local target = vlayer_control_type_list[disp[vlayer_gui_control_type.name].selected_index]
- local entities --- @type LuaEntity[]
-
- if config.power_on_space and event.surface and event.surface.platform and target == "energy" then
- if (player.force --[[@as LuaForce]]).technologies[config.power_on_space_research.name].level >= config.power_on_space_research.level then
- entities = event.surface.find_entities_filtered{ area = area, name = "constant-combinator", force = player.force }
- else
- player.print{ "vlayer.power-on-space-research", config.power_on_space_research.name, config.power_on_space_research.level }
- return
- end
- else
- entities = event.surface.find_entities_filtered{ area = area, name = "steel-chest", force = player.force }
- end
-
- if #entities == 0 then
- player.print{ "vlayer.steel-chest-detect" }
- return
- elseif #entities > 1 then
- player.print{ "vlayer.result-unable", { "vlayer.control-type-" .. target:gsub("_", "-") }, { "vlayer.result-multiple" } }
- return
- end
-
- if not entities[1] then
- return
- end
-
- local e = entities[1]
- local e_pos = { x = string.format("%.1f", e.position.x), y = string.format("%.1f", e.position.y) }
- local e_circ = nil -- e.get_wire_connectors{ or_create = false }
-
- if e.name and e.name == "steel-chest" and (not assert(e.get_inventory(defines.inventory.chest)).is_empty()) then
- player.print{ "vlayer.steel-chest-empty" }
- return
- end
-
- if (vlayer.get_interface_counts()[target] >= config.interface_limit[target]) then
- player.print{ "vlayer.result-unable", { "vlayer.control-type-" .. target:gsub("_", "-") }, { "vlayer.result-limit" } }
- return
- end
-
- e.destroy()
-
- if target == "energy" then
- if not vlayer.create_energy_interface(event.surface, e_pos, player) then
- player.print{ "vlayer.result-unable", { "vlayer.control-type-energy" }, { "vlayer.result-space" } }
- return
- end
- elseif target == "circuit" then
- vlayer.create_circuit_interface(event.surface, e_pos, e_circ, player)
- elseif target == "storage_input" then
- vlayer.create_input_interface(event.surface, e_pos, e_circ, player)
- elseif target == "storage_output" then
- vlayer.create_output_interface(event.surface, e_pos, e_circ, player)
- end
-
- game.print{ "vlayer.interface-result", player.name, pos_to_gps_string(e_pos, event.surface.name), { "vlayer.result-build" }, { "vlayer.control-type-" .. target:gsub("_", "-") } }
-end)
-
---- Display label for the number of solar panels
--- @element vlayer_gui_display_item_solar_name
-local vlayer_gui_display_item_solar_name = Gui.define("vlayer_gui_display_item_solar_name")
- :draw{
- type = "label",
- name = Gui.from_name,
- caption = { "vlayer.display-item-solar" },
- style = "heading_2_label",
- }:style{
- width = 200,
- }
-
-local vlayer_gui_display_item_solar_count = Gui.define("vlayer_gui_display_item_solar_count")
- :draw{
- type = "progressbar",
- name = Gui.from_name,
- caption = "",
- value = 0,
- style = "electric_satisfaction_statistics_progressbar",
- }:style{
- width = 200,
- font = "heading-2",
- }
-
---- Display label for the number of accumulators
--- @element vlayer_gui_display_item_accumulator_name
-local vlayer_gui_display_item_accumulator_name = Gui.define("vlayer_gui_display_item_accumulator_name")
- :draw{
- type = "label",
- name = Gui.from_name,
- caption = { "vlayer.display-item-accumulator" },
- style = "heading_2_label",
- }:style{
- width = 200,
- }
-
-local vlayer_gui_display_item_accumulator_count = Gui.define("vlayer_gui_display_item_accumulator_count")
- :draw{
- type = "progressbar",
- name = Gui.from_name,
- caption = "",
- value = 0,
- style = "electric_satisfaction_statistics_progressbar",
- }:style{
- width = 200,
- font = "heading-2",
- }
-
---- Display label for the surface area
--- @element vlayer_gui_display_signal_surface_area_name
-local vlayer_gui_display_signal_surface_area_name = Gui.define("vlayer_gui_display_signal_surface_area_name")
- :draw{
- type = "label",
- name = Gui.from_name,
- caption = { "vlayer.display-remaining-surface-area" },
- tooltip = { "vlayer.display-remaining-surface-area-tooltip" },
- style = "heading_2_label",
- }:style{
- width = 200,
- }
-
-local vlayer_gui_display_signal_surface_area_count = Gui.define("vlayer_gui_display_signal_surface_area_count")
- :draw{
- type = "progressbar",
- name = Gui.from_name,
- caption = "",
- value = 0,
- style = "electric_satisfaction_statistics_progressbar",
- }:style{
- width = 200,
- font = "heading-2",
- }
-
---- Display label for the sustained energy production
--- @element vlayer_gui_display_signal_sustained_name
-local vlayer_gui_display_signal_sustained_name = Gui.define("vlayer_gui_display_signal_sustained_name")
- :draw{
- type = "label",
- name = Gui.from_name,
- caption = { "vlayer.display-sustained-production" },
- tooltip = { "vlayer.display-sustained-production-tooltip" },
- style = "heading_2_label",
- }:style{
- width = 200,
- }
-
-local vlayer_gui_display_signal_sustained_count = Gui.define("vlayer_gui_display_signal_sustained_count")
- :draw{
- type = "progressbar",
- name = Gui.from_name,
- caption = "",
- value = 0,
- style = "electric_satisfaction_statistics_progressbar",
- }:style{
- width = 200,
- font = "heading-2",
- }
-
---- Display label for the current energy production
--- @element vlayer_gui_display_signal_production_name
-local vlayer_gui_display_signal_production_name = Gui.define("vlayer_gui_display_signal_production_name")
- :draw{
- type = "label",
- name = Gui.from_name,
- caption = { "vlayer.display-current-production" },
- tooltip = { "vlayer.display-current-production-tooltip" },
- style = "heading_2_label",
- }:style{
- width = 200,
- }
-
-local vlayer_gui_display_signal_production_count = Gui.define("vlayer_gui_display_signal_production_count")
- :draw{
- type = "progressbar",
- name = Gui.from_name,
- caption = "",
- value = 0,
- style = "electric_satisfaction_statistics_progressbar",
- }:style{
- width = 200,
- font = "heading-2",
- }
-
---- Display label for the sustained energy capacity
--- @element vlayer_gui_display_signal_capacity_name
-local vlayer_gui_display_signal_capacity_name = Gui.define("vlayer_gui_display_signal_capacity_name")
- :draw{
- type = "label",
- name = Gui.from_name,
- caption = { "vlayer.display-current-capacity" },
- tooltip = { "vlayer.display-current-capacity-tooltip" },
- style = "heading_2_label",
- }:style{
- width = 200,
- }
-
-local vlayer_gui_display_signal_capacity_count = Gui.define("vlayer_gui_display_signal_capacity_count")
- :draw{
- type = "progressbar",
- name = Gui.from_name,
- caption = "",
- value = 0,
- style = "electric_satisfaction_statistics_progressbar",
- }:style{
- width = 200,
- font = "heading-2",
- }
-
---- A vertical flow containing all the displays labels and their counts
--- @element vlayer_display_set
-local vlayer_display_set = Gui.define("vlayer_display_set")
- :draw(function(_, parent, name)
- local vlayer_set = parent.add{ type = "flow", direction = "vertical", name = name }
- local disp = Gui.elements.scroll_table(vlayer_set, 400, 2, "disp")
-
- vlayer_gui_display_item_solar_name(disp)
- vlayer_gui_display_item_solar_count(disp)
- vlayer_gui_display_item_accumulator_name(disp)
- vlayer_gui_display_item_accumulator_count(disp)
- vlayer_gui_display_signal_surface_area_name(disp)
- vlayer_gui_display_signal_surface_area_count(disp)
- vlayer_gui_display_signal_sustained_name(disp)
- vlayer_gui_display_signal_sustained_count(disp)
- vlayer_gui_display_signal_production_name(disp)
- vlayer_gui_display_signal_production_count(disp)
- vlayer_gui_display_signal_capacity_name(disp)
- vlayer_gui_display_signal_capacity_count(disp)
-
- return vlayer_set
- end)
-
-local function vlayer_gui_list_refresh(player)
- local container = Gui.get_left_element(vlayer_container, player)
- local disp = container.frame["vlayer_st_2"].disp.table
- local target = disp[vlayer_gui_control_type.name].selected_index
- local full_list = {}
-
- if target then
- local interface = vlayer.get_interfaces()[vlayer_control_type_list[target]]
-
- for i = 1, vlayer.get_interface_counts()[vlayer_control_type_list[target]], 1 do
- local entity = assert(interface[i])
- local entity_position = entity.position
- table.insert(full_list, i .. " X " .. entity_position.x .. " Y " .. entity_position.y)
- end
-
- disp[vlayer_gui_control_list.name].items = full_list
- end
-end
-
---- A drop down list filter by this type
--- @element vlayer_gui_control_type
-vlayer_gui_control_type = Gui.define("vlayer_gui_control_type")
- :draw{
- type = "drop-down",
- name = Gui.from_name,
- items = { { "vlayer.control-type-energy" }, { "vlayer.control-type-circuit" }, { "vlayer.control-type-storage-input" }, { "vlayer.control-type-storage-output" } },
- selected_index = 1,
- }:style{
- width = 200,
- }:on_selection_state_changed(function(def, player, element)
- vlayer_gui_list_refresh(player)
- end)
-
---- A drop down list to see the exact item to remove
--- @element vlayer_gui_control_list
-vlayer_gui_control_list = Gui.define("vlayer_gui_control_list")
- :draw{
- type = "drop-down",
- name = Gui.from_name,
- }:style{
- width = 200,
- }
-
---- A button to refresh the remove list
--- @element vlayer_gui_control_refresh
-local vlayer_gui_control_refresh = Gui.define("vlayer_gui_control_refresh")
- :draw{
- type = "button",
- name = Gui.from_name,
- caption = { "vlayer.control-refresh" },
- }:style{
- width = 200,
- }:on_click(function(def, player, element)
- vlayer_gui_list_refresh(player)
- end)
-
---- A button to check if the item is the one wanted to remove
--- @element vlayer_gui_control_see
-local vlayer_gui_control_see = Gui.define("vlayer_gui_control_see")
- :draw{
- type = "button",
- name = Gui.from_name,
- caption = { "vlayer.control-see" },
- }:style{
- width = 200,
- }:on_click(function(def, player, element, event)
- local target = assert(element.parent)[vlayer_gui_control_type.name].selected_index
- local n = assert(element.parent)[vlayer_gui_control_list.name].selected_index
-
- if target and vlayer_control_type_list[target] and n > 0 then
- local i = vlayer.get_interfaces()
- local entity = i[vlayer_control_type_list[target]][n]
- if entity and entity.valid then
- player.set_controller{ type = defines.controllers.remote, position = entity.position, surface = entity.surface }
- player.print{ "vlayer.result-interface-location", { "vlayer.control-type-" .. vlayer_control_type_list[target]:gsub("_", "-") }, pos_to_gps_string(entity.position, entity.surface.name) }
- end
- end
- end)
-
---- A button used to build the vlayer interface
--- @element vlayer_gui_control_build
-local vlayer_gui_control_build = Gui.define("vlayer_gui_control_build")
- :draw{
- type = "button",
- name = Gui.from_name,
- caption = { "vlayer.control-build" },
- }:style{
- width = 200,
- }:on_click(function(def, player, element)
- if SelectArea:stop(player) then
- player.print{ "exp_util.selection_exit", { "vlayer.main-tooltip" } }
- else
- SelectArea:start(player)
- player.print{ "exp_util.selection_enter", { "vlayer.main-tooltip" } }
- end
-
- vlayer_gui_list_refresh(player)
- end)
-
---- A button used to remove the vlayer interface
--- @element vlayer_gui_control_remove
-local vlayer_gui_control_remove = Gui.define("vlayer_gui_control_remove")
- :draw{
- type = "button",
- name = Gui.from_name,
- caption = { "vlayer.control-remove" },
- }:style{
- width = 200,
- }:on_click(function(def, player, element)
- local target = assert(element.parent)[vlayer_gui_control_type.name].selected_index
- local n = assert(element.parent)[vlayer_gui_control_list.name].selected_index
-
- if target and vlayer_control_type_list[target] and n > 0 then
- local i = vlayer.get_interfaces()
-
- if i and i[vlayer_control_type_list[target]] then
- local entity = assert(i[vlayer_control_type_list[target]][n])
- local interface_type, interface_surface, interface_position = vlayer.remove_interface(entity.surface, entity.position)
-
- if interface_type then
- game.print{ "vlayer.interface-result", player.name, pos_to_gps_string(interface_position, interface_surface.name), { "vlayer.result-remove" }, { "vlayer.control-type-" .. interface_type } }
- end
- end
- end
-
- vlayer_gui_list_refresh(player)
- end)
-
---- A vertical flow containing all the control buttons
--- @element vlayer_control_set
-local vlayer_control_set = Gui.define("vlayer_control_set")
- :draw(function(_, parent, name)
- local player = Gui.get_player(parent)
- local vlayer_set = parent.add{ type = "flow", direction = "vertical", name = name }
- local disp = Gui.elements.scroll_table(vlayer_set, 400, 2, "disp")
-
- vlayer_gui_control_type(disp)
- vlayer_gui_control_list(disp)
- vlayer_gui_control_refresh(disp)
- vlayer_gui_control_see(disp)
- local b = vlayer_gui_control_build(disp)
- local r = vlayer_gui_control_remove(disp)
- local v = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit")
- b.visible = v
- r.visible = v
-
- return vlayer_set
- end)
-
---- The main container for the vlayer gui
--- @element vlayer_container
-vlayer_container = Gui.define("vlayer_container")
- :draw(function(definition, parent)
- local container = Gui.elements.container(parent, 400)
-
- vlayer_display_set(container, "vlayer_st_1")
- vlayer_control_set(container, "vlayer_st_2")
-
- return container.parent
- end)
-
---- Add the element to the left flow with a toolbar button
-Gui.add_left_element(vlayer_container, false)
-Gui.toolbar.create_button{
- name = "vlayer_toggle",
- left_element = vlayer_container,
- sprite = "entity/solar-panel",
- tooltip = { "vlayer.main-tooltip" },
- visible = function(player, element)
- return Roles.player_has_permission(player, "exp_scenario.gui.vlayer")
- end
-}
-
---- Update the visibly of the buttons based on a players roles
-local function role_update_event(event)
- local player = game.players[event.player_index]
- local visible = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit")
- local container = Gui.get_left_element(vlayer_container, player)
- local disp = container.frame["vlayer_st_2"].disp.table
- disp[vlayer_gui_control_build.name].visible = visible
- disp[vlayer_gui_control_remove.name].visible = visible
-end
-
-Event.add(Roles.events.on_player_roles_changed, role_update_event)
-
-Event.on_nth_tick(config.update_tick_gui, function(_)
- local stats = vlayer.get_statistics()
- local items = vlayer.get_items()
- local items_alloc = vlayer.get_allocated_items()
-
- local vlayer_display = {
- [vlayer_gui_display_item_solar_count.name] = {
- val = (items_alloc["solar-panel"] / math.max(items["solar-panel"], 1)),
- cap = format_number(items_alloc["solar-panel"], false) .. " / " .. format_number(items["solar-panel"], false),
- },
- [vlayer_gui_display_item_accumulator_count.name] = {
- val = (items_alloc["accumulator"] / math.max(items["accumulator"], 1)),
- cap = format_number(items_alloc["accumulator"], false) .. " / " .. format_number(items["accumulator"], false),
- },
- [vlayer_gui_display_signal_surface_area_count.name] = {
- val = (stats.total_surface_area / math.max(stats.surface_area, 1)),
- cap = format_number(stats.remaining_surface_area)
- },
- [vlayer_gui_display_signal_sustained_count.name] = {
- val = (stats.energy_sustained / math.max(stats.energy_total_production, 1)),
- cap = format_energy(stats.energy_sustained, "W") .. " / " .. format_energy(stats.energy_total_production, "W")
- },
- [vlayer_gui_display_signal_production_count.name] = {
- val = (stats.energy_production / math.max(stats.energy_max, 1)),
- cap = format_energy(stats.energy_production, "W") .. " / " .. format_energy(stats.energy_max, "W"),
- },
- [vlayer_gui_display_signal_capacity_count.name] = {
- val = (stats.energy_storage / math.max(stats.energy_capacity, 1)),
- cap = format_energy(stats.energy_storage, "J") .. " / " .. format_energy(stats.energy_capacity, "J"),
- },
- }
-
- for _, player in pairs(game.connected_players) do
- local container = Gui.get_left_element(vlayer_container, player)
- local disp = container.frame["vlayer_st_1"].disp.table
-
- for k, v in pairs(vlayer_display) do
- disp[k].caption = v.cap
-
- if v.val then
- disp[k].value = v.val
- end
- end
- end
-end)
diff --git a/exp_legacy/module/modules/gui/warp-list.lua b/exp_legacy/module/modules/gui/warp-list.lua
deleted file mode 100644
index 5c456558..00000000
--- a/exp_legacy/module/modules/gui/warp-list.lua
+++ /dev/null
@@ -1,909 +0,0 @@
---[[-- Gui Module - Warp List
- - Adds a warp list gui which allows players to add and remove warp points
- @gui Warps-List
- @alias warp_list
-]]
-
-local ExpUtil = require("modules/exp_util")
-local Gui = require("modules/exp_gui")
-local Datastore = require("modules.exp_legacy.expcore.datastore") --- @dep expcore.datastore
-local Storage = require("modules/exp_util/storage")
-local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event
-local Roles = require("modules/exp_roles")
-local Colors = require("modules/exp_util/include/color")
-local config = require("modules.exp_legacy.config.gui.warps") --- @dep config.gui.warps
-local Warps = require("modules.exp_legacy.modules.control.warps") --- @dep modules.control.warps
-
-local format_time = ExpUtil.format_time_factory_locale{ format = "short", hours = true, minutes = true }
-
---- Stores all data for the warp gui
-local WrapGuiData = Datastore.connect("WrapGuiData")
-WrapGuiData:set_serializer(Datastore.name_serializer)
-local PlayerInRange = WrapGuiData:combine("PlayerInRange")
-PlayerInRange:set_default(false)
-local PlayerCooldown = WrapGuiData:combine("PlayerCooldown")
-PlayerCooldown:set_default(0)
-
---- Table that stores a boolean value of weather to keep the warp gui open
-local keep_gui_open = {}
-Storage.register(keep_gui_open, function(tbl)
- keep_gui_open = tbl
-end)
-
---- Styles used for sprite buttons
-local Styles = {
- sprite22 = { height = 22, width = 22, padding = -2 },
- sprite32 = { height = 32, width = 32, left_margin = 1 },
-}
---- Status icon of a warp
-local warp_status_icons = {
- cooldown = "[img=utility/multiplayer_waiting_icon]",
- not_available = "[img=utility/set_bar_slot]",
- bypass = "[img=utility/side_menu_bonus_icon]",
- current = "[img=utility/side_menu_map_icon]",
- connected = "[img=utility/logistic_network_panel_white]",
- different = "[img=utility/warning_white]",
-}
-
---- Returns if a player is allowed to edit the given warp
---- If a player is allowed to use the edit buttons
-local function check_player_permissions(player, action, warp)
- -- Check if the action is allow edit and then check bypass settings
- if action == "allow_edit_warp" then
- -- Check if the warp is the spawn then it cant be edited
- local spawn_id = Warps.get_spawn_warp_id(player.force.name)
- if spawn_id == warp.warp_id then
- return false
- end
-
- -- Check if the player being the last to edit will override existing permisisons
- if config.user_can_edit_own_warps and warp.last_edit_name == player.name then
- return true
- end
- end
-
- -- Check player has permission based on value in the config
- local action_config = config[action]
- if action_config == "all" then
- return true
- elseif action_config == "admin" then
- return player.admin
- elseif action_config == "exp_roles" then
- return Roles.player_has_permission(player, config["exp_roles_" .. action])
- end
-
- -- Return false as all other conditions have not been met
- return false
-end
-
---- Will add a new warp to the list, checks if the player is too close to an existing one
--- @element add_new_warp
-local add_new_warp = Gui.define("add_new_warp")
- :draw{
- type = "sprite-button",
- sprite = "utility/add",
- tooltip = { "warp-list.add-tooltip" },
- style = "shortcut_bar_button",
- name = Gui.from_name,
- }
- :style(Styles.sprite22)
- :on_click(function(def, player, element)
- if player.controller_type ~= defines.controllers.character then return end
- -- Add the new warp
- local force_name = player.force.name
- local surface = player.physical_surface
- local position = player.physical_position
-
- -- Check if the warp is too close to water
- local water_tiles = surface.find_tiles_filtered{ collision_mask = "water_tile", radius = config.standard_proximity_radius + 1, position = position }
- if #water_tiles > 0 then
- player.print(
- { "expcore-commands.command-fail", { "warp-list.too-close-to-water", config.standard_proximity_radius + 1 } },
- { color = Colors.orange_red }
- )
- if game.player then game.player.play_sound{ path = "utility/wire_pickup" } end
- for _, tile in pairs(water_tiles) do
- rendering.draw_sprite{
- sprite = "utility/rail_path_not_possible",
- x_scale = 0.5,
- y_scale = 0.5,
- target = tile.position,
- surface = surface,
- players = { player },
- time_to_live = 60,
- }
- end
-
- return
- end
-
- -- Check if there are player entities in the way (has a bigger radius because the enities that can be placed by a player are larger)
- local entities = surface.find_entities_filtered{
- radius = config.standard_proximity_radius + 2.5,
- position = position,
- collision_mask = {
- "item", "object", "player", "water_tile",
- },
- }
- -- Remove 1 because that is the current player
- if #entities > 1 then
- player.print(
- { "expcore-commands.command-fail", { "warp-list.too-close-to-entities", config.standard_proximity_radius + 2.5 } },
- { color = Colors.orange_red }
- )
- if game.player then game.player.play_sound{ path = "utility/wire_pickup" } end
- local character = player.character
- for _, entity in pairs(entities) do
- if entity ~= character then
- rendering.draw_sprite{
- sprite = "utility/rail_path_not_possible",
- x_scale = 0.5,
- y_scale = 0.5,
- target = entity,
- surface = surface,
- players = { player },
- time_to_live = 60,
- }
- end
- end
-
- return
- end
-
- -- Create the warp
- local warp_id = Warps.add_warp(force_name, surface, position, player.name)
- Warps.make_warp_tag(warp_id)
- Warps.make_warp_area(warp_id)
- end)
-
---- Warp icon button, this will trigger a warp when the player is able to
--- @element warp_icon_button
-local warp_icon_button = Gui.define("warp_icon_button")
- :draw(function(def, parent, warp)
- local warp_position = warp.position
-
- -- The SpritePath type is not the same as the SignalID type
- local sprite = warp.icon.type .. "/" .. warp.icon.name
- if warp.icon.type == "virtual" then
- sprite = "virtual-signal/" .. warp.icon.name
- end
-
- -- Draw the element
- return parent.add{
- type = "sprite-button",
- sprite = sprite,
- name = def.name,
- tooltip = { "warp-list.goto-tooltip", warp_position.x, warp_position.y },
- style = "slot_button",
- }
- end)
- :style(Styles.sprite32)
- :on_click(function(def, player, element)
- if element.type == "choose-elem-button" then return end
- local warp_id = assert(element.parent).caption
- Warps.teleport_player(warp_id, player)
-
- -- Reset the warp cooldown if the player does not have unlimited warps
- if not check_player_permissions(player, "bypass_warp_cooldown") then
- PlayerCooldown:set(player, config.update_smoothing * config.cooldown_duration)
- end
-
- PlayerInRange:set(player, warp_id)
- end)
-
---- The button that is visible when the warp is in edit state
--- @element warp_icon_editing
-local warp_icon_editing = Gui.define("warp_icon_editing")
- :draw(function(def, parent, warp)
- return parent.add{
- name = def.name,
- type = "choose-elem-button",
- elem_type = "signal",
- signal = { type = warp.icon.type, name = warp.icon.name },
- tooltip = { "warp-list.goto-edit" },
- }
- end)
- :style(Styles.sprite32)
-
---- Warp label, visible if the player is not in edit state
--- @element warp_label
-local warp_label = Gui.define("warp_label")
- :draw(function(def, parent, warp)
- local last_edit_name = warp.last_edit_name
- local last_edit_time = warp.last_edit_time
- -- Draw the element
- return parent.add{
- type = "label",
- caption = warp.name,
- tooltip = { "warp-list.last-edit", last_edit_name, format_time(last_edit_time) },
- name = def.name,
- }
- end)
- :style{
- single_line = true,
- left_padding = 2,
- right_padding = 2,
- horizontally_stretchable = true,
- }
- :on_click(function(def, player, element)
- local warp_id = assert(element.parent).caption
- local warp = Warps.get_warp(warp_id)
- player.set_controller{ type = defines.controllers.remote, position = warp.position, surface = warp.surface }
- end)
-
---- Warp status, visible if the player is not in edit state
---- This will show if the warp is connected or not
--- @element warp_status
-local warp_status = Gui.define("warp_status")
- :draw{
- type = "label",
- caption = "[img=utility/electricity_icon_unplugged]", -- Temporary icon
- name = Gui.from_name,
- }
- :style{
- -- When editing mode because textbox is larger the icon would move up.
- top_padding = 1,
- single_line = false,
- }
-
---- Warp textfield, visible if the player is in edit state
--- @element warp_textfield
-local warp_textfield = Gui.define("warp_textfield")
- :draw(function(def, parent, warp)
- -- Draw the element
- return parent.add{
- type = "textfield",
- text = warp.name,
- clear_and_focus_on_right_click = true,
- name = def.name,
- }
- end)
- :style{
- -- Required fields to make it squashable and strechable.
- minimal_width = 10,
- maximal_width = 300,
- horizontally_squashable = true,
- horizontally_stretchable = true,
- -- Other styling
- height = 22,
- padding = -2,
- left_margin = 2,
- right_margin = 2,
- }
- :on_confirmed(function(def, player, element)
- local warp_id = assert(element.parent).caption
- local warp_name = element.text
- local warp_icon = assert(element.parent).parent["icon-" .. warp_id][warp_icon_editing.name].elem_value --[[@as SignalID]]
- if warp_icon.type == nil then warp_icon.type = "item" end
- Warps.set_editing(warp_id, player.name)
- Warps.update_warp(warp_id, warp_name, warp_icon, player.name)
- end)
-
---- Confirms the edit to name or icon of the warp
--- @element confirm_edit_button
-local confirm_edit_button = Gui.define("confirm_edit_button")
- :draw{
- type = "sprite-button",
- sprite = "utility/confirm_slot",
- tooltip = { "warp-list.confirm-tooltip" },
- style = "shortcut_bar_button_green",
- name = Gui.from_name,
- }
- :style(Styles.sprite22)
- :on_click(function(def, player, element)
- local parent = assert(element.parent)
- local grandparent = assert(parent.parent)
- local warp_id = parent.caption --[[@as string]]
- local name_flow = grandparent["name-" .. warp_id]
- local icon_flow = grandparent["icon-" .. warp_id]
- local warp_name = name_flow[warp_textfield.name].text
- local warp_icon = icon_flow[warp_icon_editing.name].elem_value --[[@as SignalID]]
- if warp_icon.type == nil then warp_icon.type = "item" end
- Warps.set_editing(warp_id, player.name)
- Warps.update_warp(warp_id, warp_name, warp_icon, player.name)
- end)
-
---- Cancels the editing changes of the selected warp name or icon
--- @element cancel_edit_button
-local cancel_edit_button = Gui.define("cancel_edit_button")
- :draw{
- type = "sprite-button",
- sprite = "utility/close_black",
- tooltip = { "warp-list.cancel-tooltip" },
- style = "shortcut_bar_button_red",
- name = Gui.from_name,
- }
- :style(Styles.sprite22)
- :on_click(function(def, player, element)
- local warp_id = assert(element.parent).caption
- -- Check if this is the first edit, if so remove the warp.
- local warp = Warps.get_warp(warp_id)
- if warp.updates == 1 then
- Warps.remove_warp(warp_id)
- return
- end
- Warps.set_editing(warp_id, player.name)
- end)
-
---- Removes a warp from the list, including the physical area and map tag
--- @element remove_warp_button
-local remove_warp_button = Gui.define("remove_warp_button")
- :draw{
- type = "sprite-button",
- sprite = "utility/trash",
- tooltip = { "warp-list.remove-tooltip" },
- style = "shortcut_bar_button_red",
- name = Gui.from_name,
- }
- :style(Styles.sprite22)
- :on_click(function(def, player, element)
- local warp_id = assert(element.parent).caption
- Warps.remove_warp(warp_id)
- end)
-
---- Opens edit mode for the warp
--- @element edit_warp_button
-local edit_warp_button = Gui.define("edit_warp_button")
- :draw{
- type = "sprite-button",
- sprite = "utility/rename_icon",
- tooltip = { "warp-list.edit-tooltip-none" },
- style = "shortcut_bar_button",
- name = Gui.from_name,
- }
- :style(Styles.sprite22)
- :on_click(function(def, player, element)
- local warp_id = assert(element.parent).caption
- Warps.set_editing(warp_id, player.name, true)
- end)
-
-local update_all_warp_elements
---- Set of three elements which make up each row of the warp table
--- @element add_warp_elements
-local add_warp_elements = Gui.define("add_warp_elements")
- :draw(function(_, parent, warp)
- -- Add icon flow, this will contain the warp button and warp icon edit button
- local icon_flow = parent.add{
- name = "icon-" .. warp.warp_id,
- type = "flow",
- caption = warp.warp_id,
- }
- icon_flow.style.padding = 0
-
- -- Add the button and the icon edit button
- warp_icon_button(icon_flow, warp)
- warp_icon_editing(icon_flow, warp)
-
- -- Add name flow, this will contain the warp label and textbox
- local name_flow = parent.add{
- type = "flow",
- name = "name-" .. warp.warp_id,
- caption = warp.warp_id,
- }
- name_flow.style.padding = 0
-
- -- Add the label and textfield of the warp
- warp_status(name_flow)
- warp_label(name_flow, warp)
- warp_textfield(name_flow, warp)
-
- -- Add button flow, this will contain buttons to manage this specific warp
- local button_flow = parent.add{
- type = "flow",
- name = "button-" .. warp.warp_id,
- caption = warp.warp_id,
- }
- button_flow.style.padding = 0
-
- -- Add both edit state buttons
- confirm_edit_button(button_flow)
- cancel_edit_button(button_flow)
- edit_warp_button(button_flow)
- remove_warp_button(button_flow)
-
- return Gui.no_return()
- end)
-
--- Removes the three elements that are added as part of the warp base
-local function remove_warp_elements(parent, warp_id)
- Gui.destroy_if_valid(parent["icon-" .. warp_id])
- Gui.destroy_if_valid(parent["name-" .. warp_id])
- Gui.destroy_if_valid(parent["button-" .. warp_id])
-end
-
---- This timer controls when a player is able to warp, eg every 60 seconds
--- @element warp_timer
-local warp_timer = Gui.define("warp_timer")
- :draw{
- type = "progressbar",
- name = Gui.from_name,
- tooltip = { "warp-list.timer-tooltip-zero", config.cooldown_duration },
- minimum_value = 0,
- maximum_value = config.cooldown_duration * config.update_smoothing,
- }
- :style{
- horizontally_stretchable = true,
- color = Colors.light_blue,
- }
-
-local warp_list_container
-
--- Helper function to style and enable or disable a button element
-local function update_warp_elements(element, warp, warp_player_is_on, on_cooldown, bypass_warp_proximity)
- -- Check if button element is valid
- if not element or not element.valid then return end
-
- local label_style = assert(element.parent).parent["name-" .. warp.warp_id][warp_label.name].style
- local warp_status_element = assert(element.parent).parent["name-" .. warp.warp_id][warp_status.name]
-
- -- If player is not on a warp
- if not warp_player_is_on then
- -- If player is allowed to warp without being on a warp. If not then disable the warp location
- if bypass_warp_proximity then
- local position = warp.position
- element.tooltip = { "warp-list.goto-bypass", position.x, position.y }
- element.enabled = true
- warp_status_element.tooltip = { "warp-list.goto-bypass", position.x, position.y }
- warp_status_element.caption = warp_status_icons.bypass
- label_style.font = "default-semibold"
- else
- element.tooltip = { "warp-list.goto-disabled" }
- element.enabled = false
- warp_status_element.tooltip = { "warp-list.goto-disabled" }
- warp_status_element.caption = warp_status_icons.not_available
- label_style.font = "default"
- end
- -- If player is on the warp that is being updated
- elseif warp_player_is_on.warp_id == warp.warp_id then
- element.tooltip = { "warp-list.goto-same-warp" }
- element.enabled = false
- warp_status_element.tooltip = { "warp-list.goto-same-warp" }
- warp_status_element.caption = warp_status_icons.current
- label_style.font = "default"
- -- If player is on cooldown
- elseif on_cooldown then
- element.tooltip = { "warp-list.goto-cooldown" }
- element.enabled = false
- warp_status_element.tooltip = { "warp-list.goto-cooldown" }
- warp_status_element.caption = warp_status_icons.cooldown
- label_style.font = "default"
- else
- -- If the warp the player is standing on is the same as the warp that is being updated
- local warp_electric_network_id = warp.electric_pole and warp.electric_pole.electric_network_id or -1
- local player_warp_electric_network_id = warp_player_is_on.electric_pole and warp_player_is_on.electric_pole.electric_network_id or -2
- if warp_electric_network_id == player_warp_electric_network_id then
- local position = warp.position
- element.tooltip = { "warp-list.goto-tooltip", position.x, position.y }
- element.enabled = true
- warp_status_element.tooltip = { "warp-list.goto-tooltip", position.x, position.y }
- warp_status_element.caption = warp_status_icons.connected
- label_style.font = "default-semibold"
- -- If the warp is not on the same network but the player is allowed to warp without being on a warp
- elseif bypass_warp_proximity then
- local position = warp.position
- element.tooltip = { "warp-list.goto-bypass-different-network", position.x, position.y }
- element.enabled = true
- warp_status_element.tooltip = { "warp-list.goto-bypass-different-network", position.x, position.y }
- warp_status_element.caption = warp_status_icons.bypass
- label_style.font = "default-semibold"
- -- If the warp is on a different network than the one the player is standing on
- else
- element.tooltip = { "warp-list.goto-different-network" }
- element.enabled = false
- warp_status_element.tooltip = { "warp-list.goto-different-network" }
- warp_status_element.caption = warp_status_icons.different
- label_style.font = "default"
- end
- end
-end
-
---- Update the warp buttons for a player
-function update_all_warp_elements(player, timer, warp_id)
- -- Get the warp table
- local container = Gui.get_left_element(warp_list_container, player)
- local scroll_table = container.frame.scroll.table
-
- -- Check if the player is currently on cooldown
- timer = timer or PlayerCooldown:get(player)
- local on_cooldown = timer > 0
- -- Get the warp the player is on
- warp_id = warp_id or PlayerInRange:get(player)
- local warp_player_is_on = warp_id and Warps.get_warp(warp_id) or nil
- -- Check player permission
- local bypass_warp_proximity = check_player_permissions(player, "bypass_warp_proximity")
-
- -- Change the enabled state of the warp buttons
- local warp_ids = Warps.get_force_warp_ids(player.force.name)
- for _, next_warp_id in pairs(warp_ids) do
- local element = scroll_table["icon-" .. next_warp_id][warp_icon_button.name]
- local next_warp = Warps.get_warp(next_warp_id)
- update_warp_elements(element, next_warp, warp_player_is_on, on_cooldown, bypass_warp_proximity)
- end
-end
-
---- Updates a warp for a player
-local function update_warp(player, warp_table, warp_id)
- local warp = Warps.get_warp(warp_id)
-
- -- If the warp no longer exists then remove the warp elements from the warp table
- if not warp then
- remove_warp_elements(warp_table, warp_id)
- return
- end
-
- -- Create the warp elements if they do not already exist
- if not warp_table["icon-" .. warp_id] then
- add_warp_elements(warp_table, warp)
- end
- local icon_flow = warp_table["icon-" .. warp_id]
- local name_flow = warp_table["name-" .. warp_id]
- local button_flow = warp_table["button-" .. warp_id]
-
- -- Create local references to the elements for this warp
- local warp_icon_element = icon_flow[warp_icon_button.name]
- local warp_icon_edit_element = icon_flow[warp_icon_editing.name]
-
- local label_element = name_flow[warp_label.name]
- local textfield_element = name_flow[warp_textfield.name]
-
- local cancel_edit_element = button_flow[cancel_edit_button.name]
- local confirm_edit_element = button_flow[confirm_edit_button.name]
-
- local edit_warp_element = button_flow[edit_warp_button.name]
- local remove_warp_element = button_flow[remove_warp_button.name]
-
- -- Hide the edit button if the player is not allowed to edit the warp
- local player_allowed_edit = check_player_permissions(player, "allow_edit_warp", warp)
- local players_editing = table.get_keys(warp.currently_editing)
- edit_warp_element.visible = player_allowed_edit
-
- -- Set the tooltip of the edit button
- if #players_editing > 0 then
- edit_warp_element.hovered_sprite = "utility/warning_icon"
- edit_warp_element.tooltip = { "warp-list.edit-tooltip", table.concat(players_editing, ", ") }
- else
- edit_warp_element.hovered_sprite = edit_warp_element.sprite
- edit_warp_element.tooltip = { "warp-list.edit-tooltip-none" }
- end
-
- -- Set the visibility of the warp elements based on whether the user is editing or not
- local player_is_editing = warp.currently_editing[player.name]
- if player_is_editing then
- -- Set the icon elements visibility
- warp_icon_element.visible = false
- warp_icon_edit_element.visible = true
- -- Set the name elements visibility
- label_element.visible = false
- textfield_element.visible = true
- textfield_element.focus()
- warp_table.parent.scroll_to_element(textfield_element, "top-third")
- -- Set the edit buttons
- cancel_edit_element.visible = true
- confirm_edit_element.visible = true
- -- Set the warp buttons
- edit_warp_element.visible = false
- remove_warp_element.visible = false
- else
- -- Set the icon elements visibility
- warp_icon_element.visible = true
- -- The SpritePath type is not the same as the SignalID type
- local sprite = warp.icon.type .. "/" .. warp.icon.name
- if warp.icon.type == "virtual" then
- sprite = "virtual-signal/" .. warp.icon.name
- end
- warp_icon_element.sprite = sprite
- -- Set icon edit to the warps icon
- warp_icon_edit_element.elem_value = warp.icon
- warp_icon_edit_element.visible = false
- -- Set the name elements visibility
- label_element.visible = true
- label_element.caption = warp.name
- textfield_element.visible = false
- textfield_element.text = warp.name
- -- Set the edit buttons
- cancel_edit_element.visible = false
- confirm_edit_element.visible = false
- -- Set the warp buttons
- edit_warp_element.visible = true and player_allowed_edit
- remove_warp_element.visible = true and player_allowed_edit
- end
-
- local timer = PlayerCooldown:get(player)
- local current_warp_id = PlayerInRange:get(player)
- local to_warp = current_warp_id and Warps.get_warp(current_warp_id) or nil
- local bypass_warp_proximity = check_player_permissions(player, "bypass_warp_proximity")
- update_warp_elements(warp_icon_element, warp, to_warp, timer > 0, bypass_warp_proximity)
-end
-
--- Update all the warps for a player
-local function update_all_warps(player, warp_table)
- local warp_ids = Warps.get_force_warp_ids(player.force.name)
- warp_table.clear()
- for _, warp_id in ipairs(warp_ids) do
- update_warp(player, warp_table, warp_id)
- end
-end
-
--- Update all warps for all players on a force
-local function update_all_warp_force(force)
- local warp_ids = Warps.get_force_warp_ids(force.name)
- for _, player in pairs(force.connected_players) do
- local container = Gui.get_left_element(warp_list_container, player)
- local warp_table = container.frame.scroll.table
-
- warp_table.clear() -- Needed to re-sort the warps
- for _, warp_id in ipairs(warp_ids) do
- update_warp(player, warp_table, warp_id)
- end
- end
-end
-
---- Main warp list container for the left flow
--- @element warp_list_container
-warp_list_container = Gui.define("warp_list_container")
- :draw(function(def, parent)
- local player = Gui.get_player(parent)
- -- Check if user has permission to add warps
- local allow_add_warp = check_player_permissions(player, "allow_add_warp")
-
- -- Draw the internal container
- local container = Gui.elements.container(parent, allow_add_warp and 268 or 220)
-
- -- Draw the header
- local header = Gui.elements.header(container, {
- name = "header",
- caption = { "warp-list.main-caption" },
- tooltip = {
- "warp-list.sub-tooltip",
- config.cooldown_duration,
- config.standard_proximity_radius,
- { "warp-list.sub-tooltip-current", warp_status_icons.current },
- { "warp-list.sub-tooltip-connected", warp_status_icons.connected },
- { "warp-list.sub-tooltip-different", warp_status_icons.different },
- { "warp-list.sub-tooltip-cooldown", warp_status_icons.cooldown },
- { "warp-list.sub-tooltip-not_available", warp_status_icons.not_available },
- { "warp-list.sub-tooltip-bypass", warp_status_icons.bypass },
- },
- })
-
- -- Draw the new warp button
- local add_new_warp_element = add_new_warp(header)
- add_new_warp_element.visible = allow_add_warp
-
- -- Draw the scroll table for the warps
- local scroll_table = Gui.elements.scroll_table(container, 250, 3, "scroll")
- -- Set the scroll panel to always show the scrollbar (not doing this will result in a changing gui size)
- assert(scroll_table.parent).vertical_scroll_policy = "always"
-
- -- Change the style of the scroll table
- local scroll_table_style = scroll_table.style
- scroll_table_style.top_cell_padding = 3
- scroll_table_style.bottom_cell_padding = 3
-
- -- Draw the warp cooldown progress bar
- local warp_timer_element = warp_timer(container)
-
- -- Change the progress of the warp timer
- local timer = PlayerCooldown:get(player)
- if timer > 0 then
- warp_timer_element.tooltip = { "warp-list.timer-tooltip", math.floor(timer / config.update_smoothing) }
- warp_timer_element.value = 1 - (timer / config.update_smoothing / config.cooldown_duration)
- else
- warp_timer_element.tooltip = { "warp-list.timer-tooltip-zero", config.cooldown_duration }
- warp_timer_element.value = 1
- end
-
- -- Add any existing warps
- update_all_warps(player, scroll_table)
-
- -- Return the external container
- return container.parent
- end)
-
---- Add the element to the left flow with a toolbar button
-Gui.add_left_element(warp_list_container, false)
-Gui.toolbar.create_button{
- name = "warp_list_toggle",
- left_element = warp_list_container,
- sprite = config.default_icon.type .. "/" .. config.default_icon.name,
- tooltip = { "warp-list.main-tooltip" },
- visible = function(player, element)
- return Roles.player_has_permission(player, "exp_scenario.gui.warp_list")
- end
-}:on_click(function(def, player, element)
- -- Set gui keep open state for player that clicked the button: true if visible, false if invisible
- keep_gui_open[player.name] = Gui.toolbar.get_button_toggled_state(def, player)
-end)
-
---- When the name of a warp is updated this is triggered
-Warps.on_update(function(_, warp, old_warp)
- -- Get the force to update, warp is nil when removed
- if warp then
- update_all_warp_force(game.forces[warp.force_name])
- else
- update_all_warp_force(game.forces[old_warp.force_name])
- end
-end)
-
---- When the player leaves or enters range of a warp this is triggered
-PlayerInRange:on_update(function(player_name, warp_id)
- local player = game.players[player_name]
-
- -- Change if the frame is visible based on if the player is in range
- if not keep_gui_open[player.name] then
- Gui.toolbar.set_left_element_visible_state(warp_list_container, player, warp_id ~= nil)
- end
-
- update_all_warp_elements(player, nil, warp_id)
-end)
-
---- Update the warp cooldown progress bars to match the current cooldown
-PlayerCooldown:on_update(function(player_name, player_cooldown)
- -- Get the progress bar element
- local player = game.players[player_name]
- local container = Gui.get_left_element(warp_list_container, player)
- local warp_timer_element = container.frame[warp_timer.name]
-
- -- Set the progress
- if player_cooldown and player_cooldown > 0 then
- warp_timer_element.tooltip = { "warp-list.timer-tooltip", math.floor(player_cooldown / config.update_smoothing) }
- warp_timer_element.value = 1 - (player_cooldown / config.update_smoothing / config.cooldown_duration)
- else
- warp_timer_element.tooltip = { "warp-list.timer-tooltip-zero", config.cooldown_duration }
- warp_timer_element.value = 1
- end
-
- -- Trigger update of buttons if cooldown is now 0
- if player_cooldown == 0 then
- update_all_warp_elements(player, player_cooldown, nil)
- end
-end)
-
---- Handles updating the timer and checking distance from a warp
-local r2 = config.standard_proximity_radius ^ 2
-local rs2 = config.spawn_proximity_radius ^ 2
-local mr2 = config.minimum_distance ^ 2
-Event.on_nth_tick(math.floor(60 / config.update_smoothing), function()
- PlayerCooldown:update_all(function(_, player_cooldown)
- if player_cooldown > 0 then return player_cooldown - 1 end
- end)
-
- local force_warps = {}
- local warps = {}
- for _, player in pairs(game.connected_players) do
- local was_in_range = PlayerInRange:get(player)
-
- -- Get the ids of all the warps on the players force
- local force_name = player.force.name
- local warp_ids = force_warps[force_name]
- if not warp_ids then
- warp_ids = Warps.get_force_warp_ids(force_name)
- force_warps[force_name] = warp_ids
- end
-
- -- Check if the force has any warps
- local closest_warp = nil --- @type { warp_id: string, name: string }?
- local closest_distance = nil
- if #warp_ids > 0 then
- local surface = player.surface
- local pos = player.physical_position
- local px, py = pos.x, pos.y
-
- -- Loop over each warp
- for _, warp_id in ipairs(warp_ids) do
- -- Check if warp id is cached
- local warp = warps[warp_id]
- if not warp then
- warp = Warps.get_warp(warp_id)
- warps[warp_id] = warp
- end
-
- -- Check if the player is within range
- local warp_pos = warp.position
- if warp.surface == surface then
- local dx, dy = px - warp_pos.x, py - warp_pos.y
- local dist = (dx * dx) + (dy * dy)
- if closest_distance == nil or dist < closest_distance then
- closest_warp = warp
- closest_distance = dist
- if dist < r2 then break end
- end
- end
- end
-
- -- Check the dist to the closest warp
- local in_range = closest_warp ~= nil and (closest_warp.warp_id == warp_ids.spawn and closest_distance < rs2 or closest_distance < r2)
- if was_in_range and not in_range then
- PlayerInRange:set(player, nil)
- elseif not was_in_range and in_range then
- --- @cast closest_warp -nil
- PlayerInRange:set(player, closest_warp.warp_id)
- end
-
- -- Change the enabled state of the add warp button
- local container = Gui.get_left_element(warp_list_container, player)
- local add_warp_element = container.frame.header[add_new_warp.name]
- local old_closest_warp_name = add_warp_element.tooltip[2] or closest_warp and closest_warp.name
- local was_able_to_make_warp = add_warp_element.enabled
- local can_make_warp = closest_distance == nil or closest_distance > mr2
- if can_make_warp and not was_able_to_make_warp then
- add_warp_element.enabled = true
- add_warp_element.tooltip = { "warp-list.add-tooltip" }
- elseif not can_make_warp and was_able_to_make_warp or closest_warp and (old_closest_warp_name ~= closest_warp.name) then
- --- @cast closest_warp -nil
- add_warp_element.enabled = false
- add_warp_element.tooltip = { "warp-list.too-close", closest_warp.name }
- end
- end
- end
-end)
-
---- When a player is created make sure that there is a spawn warp created
-Event.add(defines.events.on_player_created, function(event)
- -- If the force has no spawn then make a spawn warp
- local player = game.players[event.player_index]
- local force = player.force --[[@as LuaForce]]
- local spawn_id = Warps.get_spawn_warp_id(force.name)
- if not spawn_id then
- local spawn_position = force.get_spawn_position(player.surface)
- spawn_id = Warps.add_warp(force.name, player.surface, spawn_position, nil, "Spawn")
- Warps.set_spawn_warp(spawn_id, force)
- Warps.make_warp_tag(spawn_id)
-
- local entities = player.surface.find_entities_filtered{ type = "electric-pole", position = spawn_position, radius = 20, limit = 1 }
- if entities and entities[1] then
- local warp = Warps.get_warp(spawn_id)
- warp.electric_pole = entities[1]
- end
- end
-end)
-
---- Update the warps when the player joins
-Event.add(defines.events.on_player_joined_game, function(event)
- local player = game.players[event.player_index]
- local container = Gui.get_left_element(warp_list_container, player)
- local scroll_table = container.frame.scroll.table
- update_all_warps(player, scroll_table)
-end)
-
---- Makes sure the right buttons are present when roles change
-local function role_update_event(event)
- local player = game.players[event.player_index]
- local frame = Gui.get_left_element(warp_list_container, player).frame
-
- -- Check if user has permission to add warps
- local allow_add_warp = check_player_permissions(player, "allow_add_warp")
- -- Update container size depending on whether the player is allowed to add warps
- assert(frame.parent).style.width = allow_add_warp and 268 or 220
-
- -- Update the warps, in case the user can now edit them
- local scroll_table = frame.scroll.table
- update_all_warps(player, scroll_table)
-
- -- Update the new warp button in case the user can now add them
- local add_new_warp_element = frame.header[add_new_warp.name]
- add_new_warp_element.visible = allow_add_warp
-end
-
-Event.add(Roles.events.on_player_roles_changed, role_update_event)
-
---- When a chart tag is removed or edited make sure it is not one that belongs to a warp
-local function maintain_tag(event)
- if not event.player_index then return end
- local tag = event.tag
- local force_name = event.force.name
- local warp_ids = Warps.get_force_warp_ids(force_name)
- for _, warp_id in pairs(warp_ids) do
- local warp = Warps.get_warp(warp_id)
- local warp_tag = warp.tag
- if not warp_tag or not warp_tag.valid or warp_tag == tag then
- if event.name == defines.events.on_chart_tag_removed then
- warp.tag = nil
- end
- Warps.make_warp_tag(warp_id)
- end
- end
-end
-
-Event.add(defines.events.on_chart_tag_modified, maintain_tag)
-Event.add(defines.events.on_chart_tag_removed, maintain_tag)
diff --git a/exp_legacy/package.json b/exp_legacy/package.json
index f5220dc7..601368c5 100644
--- a/exp_legacy/package.json
+++ b/exp_legacy/package.json
@@ -5,6 +5,7 @@
"author": "Cooldude2606 ",
"license": "MIT",
"repository": "explosivegaming/ExpCluster",
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production"
diff --git a/exp_legacy/webpack.config.js b/exp_legacy/webpack.config.cjs
similarity index 100%
rename from exp_legacy/webpack.config.js
rename to exp_legacy/webpack.config.cjs
diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts
index 45b79890..6d5b46f5 100644
--- a/exp_roles/controller.ts
+++ b/exp_roles/controller.ts
@@ -1,13 +1,20 @@
-import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller";
+import { BaseControllerPlugin, InstanceRecord, type Controller } from "@clusterio/controller";
import * as lib from "@clusterio/lib";
-import * as messages from "./messages";
-import { SeedRole, seedRoles, flattenSeedPermissions } from "./seed";
+import * as messages from "./messages.js";
import * as path from "node:path";
+const loaded = new WeakMap();
+
export class ControllerPlugin extends BaseControllerPlugin {
roleMeta!: lib.SubscribableDatastore;
+ /** The plugin loaded on a controller, for the exp_scenario seed. */
+ static get(controller: Controller) {
+ return loaded.get(controller);
+ }
+
async init() {
+ loaded.set(this.controller, this);
const databaseDirectory = this.controller.config.get("controller.database_directory");
this.roleMeta = new lib.SubscribableDatastore(
@@ -35,7 +42,6 @@ export class ControllerPlugin extends BaseControllerPlugin {
this.controller.handle(messages.RoleListRequest, this.handleRoleListRequest.bind(this));
this.controller.handle(messages.RoleMetaUpdateRequest, this.handleRoleMetaUpdateRequest.bind(this));
- this.controller.handle(messages.SeedRolesRequest, this.handleSeedRolesRequest.bind(this));
this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this));
this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this));
@@ -45,68 +51,6 @@ export class ControllerPlugin extends BaseControllerPlugin {
await this.roleMeta.save();
}
- /*
- Seeding
- */
-
- /**
- * Create the roles the scenario shipped with. Roles which already exist
- * by name are reused and only gain the seed permissions.
- */
- async handleSeedRolesRequest() {
- for (const [index, seedRole] of seedRoles.entries()) {
- const role = this.seedRole(seedRole);
- if (!role) {
- continue;
- }
-
- this.roleMeta.set(new messages.RoleMetaRecord(
- role.id,
- index + 1,
- seedRole.priority ?? 0,
- seedRole.shortHand,
- "",
- seedRole.color,
- seedRole.autoAssignHours === undefined ? null : seedRole.autoAssignHours * 3600000,
- seedRole.blockAutoAssign ?? false,
- ));
- }
-
- this.logger.info(`Seeded ${seedRoles.length} roles`);
- }
-
- /** Find or create the clusterio role for a seed role, returns undefined if it has no role to use. */
- seedRole(seedRole: SeedRole) {
- const roles = this.controller.roles;
- if (seedRole.isAdmin) {
- return roles.get(lib.Role.DefaultAdminRoleId);
- }
- if (seedRole.isDefault) {
- const defaultRoleId = this.controller.config.get("controller.default_role_id");
- return defaultRoleId !== null ? roles.get(defaultRoleId) : undefined;
- }
-
- const permissions = flattenSeedPermissions(seedRole);
- for (const permission of permissions) {
- if (!lib.permissions.has(permission)) {
- this.logger.warn(`Seed role ${seedRole.name} grants unknown permission ${permission}`);
- }
- }
-
- let role = [...roles.valuesMutable()].find(other => other.name === seedRole.name);
- if (role) {
- for (const permission of permissions) {
- role.permissions.add(permission);
- }
- } else {
- const id = Math.max(5, ...[...roles.keys()].map(other => other + 1));
- role = new lib.Role(id, seedRole.name, "", permissions);
- this.logger.info(`Created role ${seedRole.name}`);
- }
- roles.set(role);
- return role;
- }
-
/*
Role properties
*/
diff --git a/exp_roles/index.ts b/exp_roles/index.ts
index d2c522fe..211f29c8 100644
--- a/exp_roles/index.ts
+++ b/exp_roles/index.ts
@@ -1,5 +1,8 @@
import * as lib from "@clusterio/lib";
-import * as messages from "./messages";
+import * as messages from "./messages.js";
+
+export * from "./messages.js";
+export type { ControllerPlugin } from "./controller.js";
declare module "@clusterio/lib" {
export interface InstanceConfigFields {
@@ -27,13 +30,12 @@ export const plugin: lib.PluginDeclaration = {
messages.RoleListRequest,
messages.RoleMetaUpdateRequest,
- messages.SeedRolesRequest,
messages.AssignmentListRequest,
messages.AssignmentUpdateRequest,
],
- instanceEntrypoint: "./dist/node/instance",
+ instanceEntrypoint: "./dist/node/instance.js",
instanceConfigFields: {
"exp_roles.sync_mode": {
description: "Synchronize in game roles with the controller",
@@ -43,7 +45,7 @@ export const plugin: lib.PluginDeclaration = {
},
},
- controllerEntrypoint: "./dist/node/controller",
+ controllerEntrypoint: "./dist/node/controller.js",
controllerConfigFields: {
},
diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts
index 208a0982..d57a5819 100644
--- a/exp_roles/instance.ts
+++ b/exp_roles/instance.ts
@@ -1,6 +1,6 @@
import { BaseInstancePlugin } from "@clusterio/host";
import * as lib from "@clusterio/lib";
-import * as messages from "./messages";
+import * as messages from "./messages.js";
/** Sent by the lua side when roles are changed in game. */
export type IpcAssignmentUpdate = {
diff --git a/exp_roles/messages.ts b/exp_roles/messages.ts
index 7ba71459..3886970b 100644
--- a/exp_roles/messages.ts
+++ b/exp_roles/messages.ts
@@ -365,18 +365,6 @@ export class RoleMetaUpdateRequest {
}
}
-/** Create the roles the scenario shipped with, see seed.ts. */
-export class SeedRolesRequest {
- declare ["constructor"]: typeof SeedRolesRequest;
- static plugin = "exp_roles" as const;
- static type = "request" as const;
- static src = "control" as const;
- static dst = "controller" as const;
- static permission = "core.role.create" as const;
-
- constructor() {}
-}
-
/*
Assignment requests
*/
diff --git a/exp_roles/module/locale/en.cfg b/exp_roles/module/locale/en.cfg
index 3ea3d06c..14cecef4 100644
--- a/exp_roles/module/locale/en.cfg
+++ b/exp_roles/module/locale/en.cfg
@@ -1,3 +1,3 @@
[exp-roles]
-game-message-assign=__1__ has been assigned: __2__ by __3__
-game-message-unassign=__1__ has been unassigned: __2__ by __3__
+game-message-assign=__1__ has been assigned to __2__ by __3__
+game-message-unassign=__1__ has been unassigned from __2__ by __3__
diff --git a/exp_roles/module/locale/zh-CN.cfg b/exp_roles/module/locale/zh-CN.cfg
index 23b5caea..bdc4db73 100644
--- a/exp_roles/module/locale/zh-CN.cfg
+++ b/exp_roles/module/locale/zh-CN.cfg
@@ -1,3 +1,3 @@
[exp-roles]
-game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__
-game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__
+game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__
+game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__
diff --git a/exp_roles/module/locale/zh-TW.cfg b/exp_roles/module/locale/zh-TW.cfg
index 23b5caea..bdc4db73 100644
--- a/exp_roles/module/locale/zh-TW.cfg
+++ b/exp_roles/module/locale/zh-TW.cfg
@@ -1,3 +1,3 @@
[exp-roles]
-game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__
-game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__
+game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__
+game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__
diff --git a/exp_roles/package.json b/exp_roles/package.json
index aa9f3112..6a09a372 100644
--- a/exp_roles/package.json
+++ b/exp_roles/package.json
@@ -5,10 +5,11 @@
"author": "Cooldude2606 ",
"license": "MIT",
"repository": "explosivegaming/ExpCluster",
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production",
- "test": "tap --disable-coverage --allow-empty-coverage test/*.test.js",
+ "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js",
"coverage": "tap --coverage-report=text test/*.test.js"
},
"engines": {
@@ -26,7 +27,6 @@
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
- "@expcluster/scenario": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"antd": "catalog:",
diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js
index bf2f843c..0549c733 100644
--- a/exp_roles/test/controller.test.js
+++ b/exp_roles/test/controller.test.js
@@ -1,20 +1,14 @@
-"use strict";
-const t = require("tap");
-const lib = require("@clusterio/lib");
-const { Controller } = require("@clusterio/controller");
-const { ControllerPlugin } = require("../dist/node/controller");
-const messages = require("../dist/node/messages");
-const { seedRoles } = require("../dist/node/seed");
-
-// Importing this defines the exp_scenario permissions the seed grants
-require("@expcluster/scenario/dist/node/permissions");
+import t from "tap";
+import * as lib from "@clusterio/lib";
+import { Controller } from "@clusterio/controller";
+import { ControllerPlugin } from "../dist/node/controller.js";
+import * as messages from "../dist/node/messages.js";
// The controller validates message classes against the link registry
lib.Link.register(messages.RoleUpdatedEvent);
lib.Link.register(messages.AssignmentUpdatedEvent);
lib.Link.register(messages.RoleListRequest);
lib.Link.register(messages.RoleMetaUpdateRequest);
-lib.Link.register(messages.SeedRolesRequest);
lib.Link.register(messages.AssignmentListRequest);
lib.Link.register(messages.AssignmentUpdateRequest);
@@ -229,22 +223,5 @@ t.test("class ControllerPlugin", t2 => {
t3.strictSame(none, null, "nothing is replayed when up to date");
});
- t2.test(".handleSeedRolesRequest() creates the roles and reuses them by name", async t3 => {
- const { plugin, controller } = await startPlugin(t3, {
- roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")],
- });
-
- await plugin.handleSeedRolesRequest();
- t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists");
-
- const moderator = [...controller.roles.values()].find(other => other.name === "Moderator");
- t3.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in");
- t3.ok(plugin.roleMeta.get(moderator.id), "the role properties are created");
- t3.strictSame(plugin.roleMeta.get(moderator.id).shortHand, "Mod", "the properties match the seed");
-
- await plugin.handleSeedRolesRequest();
- t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles");
- });
-
t2.end();
});
diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js
index cb3db308..e0651358 100644
--- a/exp_roles/test/instance.test.js
+++ b/exp_roles/test/instance.test.js
@@ -1,10 +1,9 @@
-"use strict";
-const t = require("tap");
-const lib = require("@clusterio/lib");
-const { Instance } = require("@clusterio/host");
-const { InstancePlugin } = require("../dist/node/instance");
-const { plugin: pluginDeclaration } = require("../dist/node/index");
-const messages = require("../dist/node/messages");
+import t from "tap";
+import * as lib from "@clusterio/lib";
+import { Instance } from "@clusterio/host";
+import { InstancePlugin } from "../dist/node/instance.js";
+import { plugin as pluginDeclaration } from "../dist/node/index.js";
+import * as messages from "../dist/node/messages.js";
// The instance validates message classes against the link registry, and the
// plugin's config fields must be defined before an InstanceConfig can set them
diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js
index 8ec26550..324d0719 100644
--- a/exp_roles/test/messages.test.js
+++ b/exp_roles/test/messages.test.js
@@ -1,7 +1,6 @@
-"use strict";
-const t = require("tap");
-const messages = require("../dist/node/messages");
-const { testMatrix, testRoundTripJsonSerialisable } = require("../../test/common");
+import t from "tap";
+import * as messages from "../dist/node/messages.js";
+import { testMatrix, testRoundTripJsonSerialisable } from "../../test/common.js";
const fullMeta = new messages.RoleMetaRecord(
7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false,
diff --git a/exp_roles/test/module.test.js b/exp_roles/test/module.test.js
index 2e5d4aa5..0acdfc29 100644
--- a/exp_roles/test/module.test.js
+++ b/exp_roles/test/module.test.js
@@ -1,14 +1,13 @@
-"use strict";
-const path = require("node:path");
-const t = require("tap");
-const { reportLuaTests } = require("../../test/lua/runner");
+import path from "node:path";
+import t from "tap";
+import { reportLuaTests } from "../../test/lua/runner.js";
-const envFile = path.join(__dirname, "module", "env.lua");
+const envFile = path.join(import.meta.dirname, "module", "env.lua");
// Each file runs in its own lua state, and each test in a fresh environment
t.test("control.lua", t2 => {
for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) {
- t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(__dirname, "module", file)));
+ t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(import.meta.dirname, "module", file)));
}
t2.end();
});
diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx
index b63ae707..c68d5f49 100644
--- a/exp_roles/web/components/RoleProperties.tsx
+++ b/exp_roles/web/components/RoleProperties.tsx
@@ -4,8 +4,8 @@ import { Button, Col, ColorPicker, Form, Input, InputNumber, Row, Switch, Toolti
import * as lib from "@clusterio/lib";
import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui";
-import { RoleColor, RoleMetaRecord, RoleMetaUpdateRequest } from "../../messages";
-import type { WebPlugin } from "..";
+import { RoleColor, RoleMetaRecord, RoleMetaUpdateRequest } from "../../messages.js";
+import { useRoles } from "..";
const MS_PER_HOUR = 3600000;
@@ -25,13 +25,13 @@ type RoleFormValues = {
* The name, description and permissions of a role are owned by clusterio, this
* only covers the properties which only mean something in game.
*/
-export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Role }) {
+export default function RoleProperties(props: { role?: lib.Role }) {
const control = useContext(ControlContext);
const account = useAccount();
+ const [roles] = useRoles();
const [form] = Form.useForm();
const [applying, setApplying] = useState(false);
- const [roles] = props.plugin.useRoles();
const record = props.role ? roles.get(props.role.id) : undefined;
const meta = record?.meta;
const canUpdate = account.hasPermission("core.role.update");
diff --git a/exp_roles/web/components/SeedRoles.tsx b/exp_roles/web/components/SeedRoles.tsx
deleted file mode 100644
index 9b7c52b2..00000000
--- a/exp_roles/web/components/SeedRoles.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React, { useContext, useState } from "react";
-import { Button, Popconfirm } from "antd";
-
-import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui";
-
-import { SeedRolesRequest } from "../../messages";
-
-/** Button on the roles page which creates the roles the scenario shipped with. */
-export default function SeedRoles() {
- const control = useContext(ControlContext);
- const account = useAccount();
- const [seeding, setSeeding] = useState(false);
-
- if (!account.hasPermission("core.role.create")) {
- return null;
- }
-
- return {
- setSeeding(true);
- control.send(new SeedRolesRequest())
- .catch(notifyErrorHandler("Error seeding roles"))
- .finally(() => setSeeding(false));
- }}
- >
-
- }
- />;
-}
diff --git a/exp_roles/web/index.tsx b/exp_roles/web/index.tsx
index 1bd55066..0753cf9c 100644
--- a/exp_roles/web/index.tsx
+++ b/exp_roles/web/index.tsx
@@ -1,26 +1,22 @@
import React, { useCallback, useSyncExternalStore } from "react";
-import { BaseWebPlugin } from "@clusterio/web_ui";
+import { WebPluginContext } from "@clusterio/web_ui";
import * as lib from "@clusterio/lib";
-import * as messages from "../messages";
+import * as messages from "../messages.js";
import RoleProperties from "./components/RoleProperties";
-import SeedRoles from "./components/SeedRoles";
-export class WebPlugin extends BaseWebPlugin {
- roles = new lib.MapSubscriber(messages.RoleUpdatedEvent, this.control);
+let roles: lib.MapSubscriber | undefined;
- async init() {
- // The core components pass a role and the plugin, which componentExtra
- // does not carry in its type
- this.componentExtra = {
- RoleViewPage: RoleProperties as React.ComponentType,
- RolesPage: SeedRoles,
- };
- }
+export function useRoles() {
+ const subscribe = useCallback((cb: () => void) => roles!.subscribe(cb), []);
+ return useSyncExternalStore(subscribe, () => roles!.getSnapshot());
+}
- useRoles() {
- const subscribe = useCallback((cb: () => void) => this.roles.subscribe(cb), []);
- return useSyncExternalStore(subscribe, () => this.roles.getSnapshot());
- }
+export default function registerPlugin(context: WebPluginContext) {
+ context.control.hooks.extensionComponents.attach("exp_roles", () => ({
+ RoleViewPage: RoleProperties,
+ }));
+
+ roles = new lib.MapSubscriber(messages.RoleUpdatedEvent, context.control);
}
diff --git a/exp_roles/webpack.config.js b/exp_roles/webpack.config.cjs
similarity index 100%
rename from exp_roles/webpack.config.js
rename to exp_roles/webpack.config.cjs
diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts
index fc761f62..cd19bd96 100644
--- a/exp_scenario/controller.ts
+++ b/exp_scenario/controller.ts
@@ -1,6 +1,157 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
+import { RoleMetaRecord } from "@expcluster/roles";
+import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups";
+import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js";
+import { ControllerPlugin as GroupsPlugin } from "@expcluster/permission-groups/dist/node/controller.js";
+import * as messages from "./messages.js";
+import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed.js";
export class ControllerPlugin extends BaseControllerPlugin {
+ async init() {
+ this.controller.handle(messages.SeedRequest, this.handleSeedRequest.bind(this));
+ }
+ /**
+ * Create the roles and permission groups the scenario shipped with.
+ *
+ * Roles which already exist by name are reused and only gain the seed
+ * permissions, groups which already exist by name are reset to the seed.
+ */
+ async handleSeedRequest() {
+ const rolesPlugin = RolesPlugin.get(this.controller);
+ const groupsPlugin = GroupsPlugin.get(this.controller);
+ if (!rolesPlugin || !groupsPlugin) {
+ throw new lib.RequestError("Seeding requires the exp_roles and exp_groups plugins");
+ }
+
+ const roleIds = new Map();
+ for (const [index, seedRole] of seedRoles.entries()) {
+ const role = this.seedRole(seedRole);
+ if (!role) {
+ continue;
+ }
+
+ roleIds.set(seedRole.name, role.id);
+ rolesPlugin.roleMeta.set(new RoleMetaRecord(
+ role.id,
+ index + 1,
+ seedRole.priority ?? 0,
+ seedRole.shortHand,
+ "",
+ seedRole.color,
+ seedRole.autoAssignHours === undefined ? null : seedRole.autoAssignHours * 3600000,
+ seedRole.blockAutoAssign ?? false,
+ ));
+ }
+
+ const groupIds = new Map();
+ for (const seedGroup of seedGroups) {
+ groupIds.set(seedGroup.name, this.seedGroup(groupsPlugin, seedGroup).id);
+ }
+
+ this.seedRoleMappings(groupsPlugin, roleIds, groupIds);
+ this.logger.info(`Seeded ${roleIds.size} roles and ${groupIds.size} permission groups`);
+ }
+
+ /** Find or create the clusterio role for a seed role, returns undefined if it has no role to use. */
+ seedRole(seedRole: SeedRole) {
+ const roles = this.controller.roles;
+ if (seedRole.isAdmin) {
+ return roles.get(lib.Role.DefaultAdminRoleId);
+ }
+ if (seedRole.isDefault) {
+ const defaultRoleId = this.controller.config.get("controller.default_role_id");
+ return defaultRoleId !== null ? roles.get(defaultRoleId) : undefined;
+ }
+
+ const permissions = flattenSeedPermissions(seedRole);
+ for (const permission of permissions) {
+ if (!lib.permissions.has(permission)) {
+ this.logger.warn(`Seed role ${seedRole.name} grants unknown permission ${permission}`);
+ }
+ }
+
+ let role = [...roles.valuesMutable()].find(other => other.name === seedRole.name);
+ if (role) {
+ for (const permission of permissions) {
+ role.permissions.add(permission);
+ }
+ } else {
+ const id = Math.max(5, ...[...roles.keys()].map(other => other + 1));
+ role = new lib.Role(id, seedRole.name, "", permissions);
+ this.logger.info(`Created role ${seedRole.name}`);
+ }
+ roles.set(role);
+ return role;
+ }
+
+ /** Find or create the permission group for a seed group. */
+ seedGroup(groupsPlugin: GroupsPlugin, seedGroup: SeedGroup) {
+ const permissions = new GroupPermissions(seedGroup.isBlacklist, [...seedGroup.inputActions]);
+ const existing = [...groupsPlugin.groups.values()].find(other => other.name === seedGroup.name);
+ const group = new GroupRecord(existing?.id ?? newId(groupsPlugin.groups), seedGroup.name, permissions);
+ if (!existing) {
+ this.logger.info(`Created permission group ${seedGroup.name}`);
+ }
+ groupsPlugin.groups.set(group);
+ return group;
+ }
+
+ /**
+ * Map each seed role onto its seed group.
+ *
+ * The mapping with the highest priority decides a player's group, so the
+ * priorities follow how exp_roles picks a player's highest role: role
+ * priority first, then the role order.
+ */
+ seedRoleMappings(groupsPlugin: GroupsPlugin, roleIds: Map, groupIds: Map) {
+ // Lowest role first, so the mapping priority rises with the role
+ const ranked = seedRoles
+ .filter(seedRole => seedRole.group !== undefined && roleIds.has(seedRole.name))
+ .reverse()
+ .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
+ const seededRoleIds = new Set(ranked.map(seedRole => roleIds.get(seedRole.name)!));
+
+ // A mapping of a single seed role is reused, every other mapping keeps its priority
+ const existing = new Map();
+ const taken = new Set();
+ for (const mapping of groupsPlugin.roleMappings.values()) {
+ const [roleId] = mapping.roleIds;
+ if (mapping.roleIds.size === 1 && seededRoleIds.has(roleId)) {
+ existing.set(roleId, mapping);
+ } else {
+ taken.add(mapping.priority);
+ }
+ }
+
+ const mappings = [];
+ let priority = 0;
+ for (const seedRole of ranked) {
+ priority += 1;
+ while (taken.has(priority)) {
+ priority += 1;
+ }
+
+ const roleId = roleIds.get(seedRole.name)!;
+ mappings.push(new RoleMappingRecord(
+ existing.get(roleId)?.id ?? newId(groupsPlugin.roleMappings),
+ new Set([roleId]),
+ groupIds.get(seedRole.group!)!,
+ priority,
+ true,
+ ));
+ }
+
+ groupsPlugin.roleMappings.setMany(mappings);
+ return mappings;
+ }
+}
+
+function newId(datastore: { has(id: number): boolean }) {
+ let id = Math.random() * 2 ** 31 | 0;
+ while (datastore.has(id)) {
+ id = Math.random() * 2 ** 31 | 0;
+ }
+ return id;
}
diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts
index b3e4bd81..203c5721 100644
--- a/exp_scenario/index.ts
+++ b/exp_scenario/index.ts
@@ -1,39 +1,45 @@
import * as lib from "@clusterio/lib";
-// import * as Messages from "./messages";
-
-// Defines a permission for every in game action and role flag used by the scenario
-import "./permissions";
-
-lib.definePermission({
- name: "exp_scenario.config.view",
- title: "View ExpScenario Config",
- description: "View the config for all submodules of ExpScenario",
-});
-
-lib.definePermission({
- name: "exp_scenario.config.edit",
- title: "Edit ExpScenario Config",
- description: "Edit the config for all submodules of ExpScenario",
-});
+import * as messages from "./messages.js";
+import { permissions as scenarioPermissions, type ScenarioPermissionName } from "./permissions.js";
declare module "@clusterio/lib" {
-
+ // Everything checked in game through exp_roles, plus the permissions checked on the controller
+ export interface Permissions extends Record {
+ "exp_scenario.config.view": never;
+ "exp_scenario.config.edit": never;
+ "exp_scenario.seed": never;
+ }
}
export const plugin: lib.PluginDeclaration = {
name: "exp_scenario",
title: "exp_scenario",
description: "Example Description. Plugin. Change me in index.ts",
- controllerEntrypoint: "./dist/node/controller",
- instanceEntrypoint: "./dist/node/instance",
+ controllerEntrypoint: "./dist/node/controller.js",
+ instanceEntrypoint: "./dist/node/instance.js",
- /*
messages: [
+ messages.SeedRequest,
+ ],
+
+ permissions: [
+ ...scenarioPermissions,
+ {
+ name: "exp_scenario.config.view",
+ title: "View ExpScenario Config",
+ description: "View the config for all submodules of ExpScenario",
+ },
+ {
+ name: "exp_scenario.config.edit",
+ title: "Edit ExpScenario Config",
+ description: "Edit the config for all submodules of ExpScenario",
+ },
+ {
+ name: "exp_scenario.seed",
+ title: "Seed ExpScenario roles and groups",
+ description: "Create the roles and permission groups the scenario shipped with",
+ },
],
webEntrypoint: "./web",
- routes: [
- "/exp_scenario",
- ],
- */
};
diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts
index 1eb52057..9c778456 100644
--- a/exp_scenario/messages.ts
+++ b/exp_scenario/messages.ts
@@ -1,95 +1,11 @@
-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;
+/** Create the roles and permission groups the scenario shipped with, see seed.ts. */
+export class SeedRequest {
+ declare ["constructor"]: typeof SeedRequest;
static plugin = "exp_scenario" as const;
- static permission = "exp_scenario.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) {
- 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 = "exp_scenario" as const;
- static permission = "exp_scenario.example.permission.request";
+ static src = "control" as const;
+ static dst = "controller" as const;
+ static permission = "exp_scenario.seed" as const;
- constructor(
- public myString: string,
- public myNumberArray: number[],
- ) {
- }
-
- static jsonSchema = Type.Object({
- "myString": Type.String(),
- "myNumberArray": Type.Array(Type.Number()),
- });
-
- static fromJSON(json: Static) {
- return new PluginExampleRequest(json.myString, json.myNumberArray);
- }
-
- static Response = plainJson(Type.Object({
- "myResponseString": Type.String(),
- "myResponseNumbers": Type.Array(Type.Number()),
- }));
-}
-
-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) {
- 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 = "exp_scenario" as const;
- static permission = "exp_scenario.example.permission.subscribe";
-
- constructor(
- public updates: ExampleSubscribableValue[],
- ) { }
-
- static jsonSchema = Type.Object({
- "updates": Type.Array(ExampleSubscribableValue.jsonSchema),
- });
-
- static fromJSON(json: Static) {
- return new this(json.updates.map(update => ExampleSubscribableValue.fromJSON(update)));
- }
+ constructor() {}
}
diff --git a/exp_scenario/module/commands/_rcon.lua b/exp_scenario/module/commands/_rcon.lua
index ef3febbc..7db7ad1f 100644
--- a/exp_scenario/module/commands/_rcon.lua
+++ b/exp_scenario/module/commands/_rcon.lua
@@ -7,7 +7,6 @@ local add_static, add_dynamic = Commands.add_rcon_static, Commands.add_rcon_dyna
add_static("Gui", require("modules/exp_gui"))
-add_static("Group", require("modules.exp_legacy.expcore.permission_groups"))
add_static("Roles", require("modules/exp_roles"))
add_static("Datastore", require("modules.exp_legacy.expcore.datastore"))
add_static("External", require("modules.exp_legacy.expcore.external"))
diff --git a/exp_scenario/module/commands/vlayer.lua b/exp_scenario/module/commands/vlayer.lua
index f4b38e96..8ba856d9 100644
--- a/exp_scenario/module/commands/vlayer.lua
+++ b/exp_scenario/module/commands/vlayer.lua
@@ -3,7 +3,7 @@ Adds a virtual layer to store power to save space.
]]
local Commands = require("modules/exp_commands")
-local vlayer = require("modules.exp_legacy.modules.control.vlayer")
+local Vlayer = require("modules/exp_scenario/control/vlayer")
--- @class ExpCommand_vlayer.commands
local commands = {}
@@ -14,7 +14,7 @@ commands.vlayer = Commands.new("vlayer-info", { "exp-commands_vlayer.description
:register(function(player)
local index = 3
local response = { "", "exp-commands_vlayer.title" } --- @type LocalisedString
- for title, value in pairs(vlayer.get_circuits()) do
+ for title, value in pairs(Vlayer.get_circuits()) do
response[index] = { "exp-commands_vlayer.result", title, value }
index = index + 1
end
diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua
index 1cd47d66..3d9dbdd4 100644
--- a/exp_scenario/module/control.lua
+++ b/exp_scenario/module/control.lua
@@ -67,6 +67,7 @@ add(require("modules/exp_scenario/control/roles"))
add(require("modules/exp_scenario/control/spawn_area"))
add(require("modules/exp_scenario/control/spectate"))
add(require("modules/exp_scenario/control/station_auto_name"))
+add(require("modules/exp_scenario/control/vlayer"))
--- Guis
add(require("modules/exp_scenario/gui/autofill"))
@@ -83,5 +84,7 @@ add(require("modules/exp_scenario/gui/rocket_info"))
add(require("modules/exp_scenario/gui/science_production"))
add(require("modules/exp_scenario/gui/surveillance"))
add(require("modules/exp_scenario/gui/task_list"))
+add(require("modules/exp_scenario/gui/vlayer"))
+add(require("modules/exp_scenario/gui/warp_list"))
add(require("modules/exp_scenario/gui/debug/shim"))
add(require("modules/exp_scenario/gui/debug/event_view"))
diff --git a/exp_scenario/module/control/roles.lua b/exp_scenario/module/control/roles.lua
index 1c4ecf66..0ffdc3a0 100644
--- a/exp_scenario/module/control/roles.lua
+++ b/exp_scenario/module/control/roles.lua
@@ -9,9 +9,6 @@ Roles.define_permission_trigger("exp_scenario.player.admin", function(player, st
player.admin = state
end)
-Roles.define_permission_trigger("exp_scenario.player.spectator", function(player, state)
- player.spectator = state
-end)
return {
events = {
diff --git a/exp_scenario/module/control/vlayer.lua b/exp_scenario/module/control/vlayer.lua
new file mode 100644
index 00000000..7d063557
--- /dev/null
+++ b/exp_scenario/module/control/vlayer.lua
@@ -0,0 +1,673 @@
+--[[-- Control - Virtual Layer
+Stores solar panels, accumulators and landfill in a virtual layer which produces power through interface entities
+]]
+
+local ExpUtil = require("modules/exp_util")
+local Storage = require("modules/exp_util/storage")
+local config = require("modules.exp_legacy.config.vlayer")
+
+local floor = math.floor
+local min = math.min
+local max = math.max
+local mega = 1000000
+
+--- @alias ExpScenario_Vlayer.InterfaceType "energy" | "circuit" | "storage_input" | "storage_output"
+
+--- @class ExpScenario_Vlayer.Interfaces
+--- @field energy LuaEntity[]
+--- @field circuit LuaEntity[]
+--- @field storage_input LuaEntity[]
+--- @field storage_output LuaEntity[]
+
+--- @class ExpScenario_Vlayer.Properties
+--- @field total_surface_area number Tiles provided by landfill
+--- @field used_surface_area number Tiles taken by allocated items
+--- @field total_production number MW of every solar panel stored, allocated or not
+--- @field production number MW of the allocated solar panels
+--- @field discharge number MW the allocated accumulators can discharge
+--- @field capacity number MJ the allocated accumulators can hold
+
+--- @class ExpScenario_Vlayer.PowerItem
+--- @field value number J released by burning one
+--- @field count number
+
+--- @class ExpScenario_Vlayer.Storage
+--- @field items table Every stored item, allocated or not
+--- @field unallocated table Items waiting for surface area
+--- @field power_items table Fuel burned when the stored energy is low
+--- @field energy number J
+
+--- @class ExpScenario_Vlayer.Statistics
+--- @field total_surface_area number
+--- @field used_surface_area number
+--- @field remaining_surface_area number Surface area left after the unallocated items are counted
+--- @field surface_area number
+--- @field production_multiplier number
+--- @field energy_max number W when the sun is up
+--- @field energy_production number W right now
+--- @field energy_total_production number W sustained if every stored solar panel was allocated
+--- @field energy_sustained number W averaged over a day
+--- @field energy_capacity number J
+--- @field energy_storage number J
+
+--- @class ExpScenario_Vlayer.Data
+--- @field interfaces ExpScenario_Vlayer.Interfaces
+--- @field properties ExpScenario_Vlayer.Properties
+--- @field storage ExpScenario_Vlayer.Storage
+--- @field surface LuaSurface | table The surface the day cycle is taken from, or a copy of the config settings
+
+--- @class ExpScenario_Vlayer
+local Vlayer = {
+ --- @package
+ events = {},
+ --- @package
+ on_nth_tick = {},
+}
+
+--- The properties of every allowed item, modded items are derived from their base game equivalent
+local allowed_items = {} --- @type table
+for name, properties in pairs(config.allowed_items) do
+ allowed_items[name] = properties
+end
+for name, properties in pairs(config.modded_items) do
+ local base = config.allowed_items[properties.base_game_equivalent]
+ local multiplier = properties.multiplier
+ allowed_items[name] = {
+ starting_value = properties.starting_value or 0,
+ required_area = base.required_area or 0,
+ surface_area = (base.surface_area or 0) * multiplier,
+ production = (base.production or 0) * multiplier,
+ capacity = (base.capacity or 0) * multiplier,
+ }
+end
+
+--- @type ExpScenario_Vlayer.Data
+local vlayer_data = {
+ interfaces = {
+ energy = {},
+ circuit = {},
+ storage_input = {},
+ storage_output = {},
+ },
+ properties = {
+ total_surface_area = 0,
+ used_surface_area = 0,
+ total_production = 0,
+ production = 0,
+ discharge = 0,
+ capacity = 0,
+ },
+ storage = {
+ items = {},
+ unallocated = {},
+ power_items = {},
+ energy = 0,
+ },
+ surface = table.deep_copy(config.surface),
+}
+
+Storage.register(vlayer_data, function(tbl)
+ vlayer_data = tbl
+end)
+
+--- Interface entities are neutral so players can not mine them, these are the prototypes used for each type
+local interface_names = {
+ energy = "electric-energy-interface",
+ circuit = "constant-combinator",
+ storage_input = "storage-chest",
+ storage_output = "requester-chest",
+} --- @type table
+
+local interface_types = {} --- @type table
+for interface_type, name in pairs(interface_names) do
+ interface_types[name] = interface_type
+end
+
+--- Statistics exposed on circuit interfaces and the signal each is sent as
+local circuit_signals = {
+ total_surface_area = "signal-A",
+ used_surface_area = "signal-U",
+ remaining_surface_area = "signal-R",
+ production_multiplier = "signal-M",
+ energy_production = "signal-P",
+ energy_sustained = "signal-S",
+ energy_capacity = "signal-C",
+ energy_storage = "signal-E",
+} --- @type table
+
+local circuit_description = {}
+for stat_name, signal_name in pairs(circuit_signals) do
+ circuit_description[#circuit_description + 1] = string.format("[virtual-signal=%s] = %s", signal_name, stat_name:gsub("_", " "))
+end
+circuit_description = table.concat(circuit_description, "\n")
+
+--- Get every stored item keyed by name, allocated or not
+--- @return table
+function Vlayer.get_items()
+ return vlayer_data.storage.items
+end
+
+--- Get the items waiting for surface area keyed by name
+--- @return table
+function Vlayer.get_unallocated_items()
+ return vlayer_data.storage.unallocated
+end
+
+--- Get the items which contribute to the properties keyed by name
+--- @return table
+function Vlayer.get_allocated_items()
+ local allocated = {}
+ local unallocated = vlayer_data.storage.unallocated
+ for name, count in pairs(vlayer_data.storage.items) do
+ allocated[name] = count - (unallocated[name] or 0)
+ end
+ return allocated
+end
+
+--- Get the interface entities of each type, do not modify
+--- @return ExpScenario_Vlayer.Interfaces
+function Vlayer.get_interfaces()
+ return vlayer_data.interfaces
+end
+
+--- Get the signal each statistic is sent as on circuit interfaces
+--- @return table
+function Vlayer.get_circuits()
+ return circuit_signals
+end
+
+--- Surface area left once the unallocated items have taken what they need
+--- @return number
+local function get_remaining_surface_area()
+ local properties = vlayer_data.properties
+ local remaining = properties.total_surface_area - properties.used_surface_area
+ for name, count in pairs(vlayer_data.storage.unallocated) do
+ remaining = remaining - allowed_items[name].required_area * count
+ end
+ return remaining
+end
+
+--- Fraction of solar production available right now, from the darkness of a real surface or the config day cycle
+--- @return number
+local function get_production_multiplier()
+ local surface = vlayer_data.surface
+ local multiplier = surface.solar_power_multiplier
+ if surface.always_day then
+ return multiplier
+ end
+
+ if surface.darkness then
+ local brightness = 1 - surface.darkness
+ if brightness < surface.min_brightness then
+ return 0
+ end
+ return multiplier * (brightness - surface.min_brightness) / (1 - surface.min_brightness)
+ end
+
+ local daytime = (game.tick % surface.ticks_per_day) / surface.ticks_per_day
+ surface.daytime = daytime
+ if daytime <= surface.dusk then
+ return multiplier
+ elseif daytime <= surface.evening then
+ return multiplier * (1 - (daytime - surface.dusk) / (surface.evening - surface.dusk))
+ elseif daytime <= surface.morning then
+ return 0
+ elseif daytime <= surface.dawn then
+ return multiplier * (daytime - surface.morning) / (surface.dawn - surface.morning)
+ end
+ return multiplier
+end
+
+--- Fraction of solar production averaged over a whole day, sunrise and sunset count for half
+--- @return number
+local function get_sustained_multiplier()
+ local surface = vlayer_data.surface
+ local multiplier = surface.solar_power_multiplier
+ if surface.always_day then
+ return multiplier
+ end
+
+ local day_duration = 1 - surface.dawn + surface.dusk
+ local sunset_duration = surface.evening - surface.dusk
+ local sunrise_duration = surface.dawn - surface.morning
+ return multiplier * (day_duration + 0.5 * (sunset_duration + sunrise_duration))
+end
+
+--- Apply the properties of an item to the layer, a negative count deallocates
+--- @param name string
+--- @param count number
+local function allocate_item(name, count)
+ local item = allowed_items[name]
+ local properties = vlayer_data.properties
+ if item.production then
+ properties.production = properties.production + item.production * count
+ properties.total_production = properties.total_production + item.production * count
+ end
+ if item.capacity then
+ properties.capacity = properties.capacity + item.capacity * count
+ end
+ if item.discharge then
+ properties.discharge = properties.discharge + item.discharge * count
+ end
+ if item.surface_area then
+ properties.total_surface_area = properties.total_surface_area + item.surface_area * count
+ end
+ if item.required_area > 0 then
+ properties.used_surface_area = properties.used_surface_area + item.required_area * count
+ end
+end
+
+--- Count the production of items which are stored but not allocated, so the total shows what more surface area would give
+--- @param name string
+--- @param count number
+local function count_unallocated_production(name, count)
+ local item = allowed_items[name]
+ if item.production then
+ vlayer_data.properties.total_production = vlayer_data.properties.total_production + item.production * count
+ end
+end
+
+for name, item in pairs(allowed_items) do
+ vlayer_data.storage.items[name] = item.starting_value
+ if item.required_area > 0 then
+ vlayer_data.storage.unallocated[name] = 0
+ end
+ if item.power then
+ vlayer_data.storage.power_items[name] = { value = assert(item.fuel_value) * mega, count = 0 }
+ end
+ allocate_item(name, item.starting_value)
+end
+
+--- Store an item, it is allocated straight away when there is surface area for it
+--- @param name string
+--- @param count number
+function Vlayer.insert_item(name, count)
+ local item = assert(allowed_items[name], "Item not allowed in the vlayer: " .. name)
+ local storage = vlayer_data.storage
+ storage.items[name] = storage.items[name] + count
+
+ if config.unlimited_surface_area or item.required_area == 0 then
+ allocate_item(name, count)
+ return
+ end
+
+ local properties = vlayer_data.properties
+ local surplus_area = properties.total_surface_area - properties.used_surface_area
+ local allocated = min(count, floor(surplus_area / item.required_area))
+ if allocated > 0 then
+ allocate_item(name, allocated)
+ end
+
+ local unallocated = count - allocated
+ storage.unallocated[name] = storage.unallocated[name] + unallocated
+ count_unallocated_production(name, unallocated)
+end
+
+--- Take an item out, unallocated items go first and landfill can only go while nothing is using its area
+--- @param name string
+--- @param count number
+--- @return number # The count actually removed
+function Vlayer.remove_item(name, count)
+ local item = assert(allowed_items[name], "Item not allowed in the vlayer: " .. name)
+ local storage = vlayer_data.storage
+ local properties = vlayer_data.properties
+ local removed = 0
+
+ if not config.unlimited_surface_area and item.required_area > 0 then
+ removed = min(count, storage.unallocated[name])
+ storage.items[name] = storage.items[name] - removed
+ storage.unallocated[name] = storage.unallocated[name] - removed
+ count_unallocated_production(name, -removed)
+ count = count - removed
+ if count == 0 then
+ return removed
+ end
+ end
+
+ local deallocate = min(count, storage.items[name])
+ if item.surface_area and item.surface_area > 0 then
+ local surplus_area = properties.total_surface_area - properties.used_surface_area
+ deallocate = min(deallocate, floor(surplus_area / item.surface_area))
+ if deallocate <= 0 then
+ return removed
+ end
+ end
+
+ storage.items[name] = storage.items[name] - deallocate
+ allocate_item(name, -deallocate)
+ return removed + deallocate
+end
+
+--- Add or take energy, used by other modules which spend the stored energy
+--- @param energy number J, negative to take
+--- @return number # J stored afterwards
+function Vlayer.energy_changed(energy)
+ local storage = vlayer_data.storage
+ storage.energy = storage.energy + energy
+ return storage.energy
+end
+
+--- Get the statistics of the layer
+--- @return ExpScenario_Vlayer.Statistics
+function Vlayer.get_statistics()
+ local properties = vlayer_data.properties
+ local production = properties.production * mega
+ local production_multiplier = get_production_multiplier()
+ local sustained_multiplier = get_sustained_multiplier()
+ local remaining_surface_area = get_remaining_surface_area()
+
+ return {
+ total_surface_area = properties.total_surface_area,
+ used_surface_area = properties.used_surface_area,
+ remaining_surface_area = remaining_surface_area,
+ surface_area = properties.total_surface_area - remaining_surface_area,
+ production_multiplier = production_multiplier,
+ energy_max = production,
+ energy_production = production * production_multiplier,
+ energy_total_production = properties.total_production * sustained_multiplier * mega,
+ energy_sustained = production * sustained_multiplier,
+ energy_capacity = properties.capacity * mega,
+ energy_storage = vlayer_data.storage.energy,
+ }
+end
+
+--- Make an entity into an interface which players can not mine or damage
+--- @param interface LuaEntity
+--- @param interface_type ExpScenario_Vlayer.InterfaceType
+--- @param player LuaPlayer?
+local function register_interface(interface, interface_type, player)
+ local interfaces = vlayer_data.interfaces[interface_type]
+ interfaces[#interfaces + 1] = interface
+ interface.last_user = player
+ interface.destructible = false
+ interface.minable_flag = false
+end
+
+--- Create an interface which powers the electric network it is placed in
+--- @param surface LuaSurface
+--- @param position MapPosition
+--- @param player LuaPlayer?
+--- @return LuaEntity? # Nil when the interface does not fit
+function Vlayer.create_energy_interface(surface, position, player)
+ if not surface.can_place_entity{ name = interface_names.energy, position = position } then
+ return nil
+ end
+
+ local interface = assert(surface.create_entity{ name = interface_names.energy, position = position, force = "neutral" })
+ register_interface(interface, "energy", player)
+ interface.operable = false
+ interface.electric_buffer_size = 0
+ interface.power_production = 0
+ interface.power_usage = 0
+ interface.energy = 0
+ return interface
+end
+
+--- Create an interface which outputs the statistics and stored items as signals
+--- @param surface LuaSurface
+--- @param position MapPosition
+--- @param player LuaPlayer?
+--- @return LuaEntity
+function Vlayer.create_circuit_interface(surface, position, player)
+ local interface = assert(surface.create_entity{ name = interface_names.circuit, position = position, force = "neutral" })
+ register_interface(interface, "circuit", player)
+ interface.combinator_description = circuit_description
+ return interface
+end
+
+--- Create an interface which stores every allowed item put into it
+--- @param surface LuaSurface
+--- @param position MapPosition
+--- @param player LuaPlayer?
+--- @return LuaEntity
+function Vlayer.create_input_interface(surface, position, player)
+ local interface = assert(surface.create_entity{ name = interface_names.storage_input, position = position, force = "neutral" })
+ register_interface(interface, "storage_input", player)
+ -- A filter nothing can match stops robots filling it with anything
+ interface.storage_filter = { name = "deconstruction-planner", quality = "normal" }
+ return interface
+end
+
+--- Create an interface which takes stored items out to fulfil its requests
+--- @param surface LuaSurface
+--- @param position MapPosition
+--- @param player LuaPlayer?
+--- @return LuaEntity
+function Vlayer.create_output_interface(surface, position, player)
+ local interface = assert(surface.create_entity{ name = interface_names.storage_output, position = position, force = "neutral" })
+ register_interface(interface, "storage_output", player)
+ return interface
+end
+
+--- Remove an interface, anything it holds is dropped in a chest next to it and stored energy is kept
+--- @param interface LuaEntity
+--- @return ExpScenario_Vlayer.InterfaceType
+function Vlayer.remove_interface(interface)
+ local interface_type = assert(interface_types[interface.name], "Entity is not a vlayer interface: " .. interface.name)
+ if interface_type == "storage_input" or interface_type == "storage_output" then
+ ExpUtil.transfer_inventory_to_surface{
+ inventory = assert(interface.get_inventory(defines.inventory.chest)),
+ surface = interface.surface,
+ name = "iron-chest",
+ allow_creation = true,
+ }
+ elseif interface_type == "energy" then
+ vlayer_data.storage.energy = vlayer_data.storage.energy + interface.energy
+ end
+
+ table.remove_element(vlayer_data.interfaces[interface_type], interface)
+ interface.destroy()
+ return interface_type
+end
+
+--- Forget interfaces which no longer exist
+--- @param interfaces LuaEntity[]
+local function prune_interfaces(interfaces)
+ for index = #interfaces, 1, -1 do
+ if not interfaces[index].valid then
+ table.remove(interfaces, index)
+ end
+ end
+end
+
+--- Store the contents of the input interfaces
+local function handle_input_interfaces()
+ local interfaces = vlayer_data.interfaces.storage_input
+ prune_interfaces(interfaces)
+ for _, interface in ipairs(interfaces) do
+ local inventory = assert(interface.get_inventory(defines.inventory.chest))
+ for _, item in pairs(inventory.get_contents()) do
+ local name = item.name
+ if allowed_items[name] then
+ -- Quality is not tracked, so higher quality is stored as extra normal items in batches of ten
+ local quality_level = prototypes.quality[item.quality].level
+ local removed, stored = 0, 0
+ if quality_level == 0 then
+ removed, stored = item.count, item.count
+ elseif item.count >= 10 then
+ local batches = floor(item.count / 10)
+ removed, stored = batches * 10, batches * (10 + quality_level * 3)
+ end
+
+ if removed > 0 then
+ local modded = config.modded_items[name]
+ if modded and config.modded_auto_downgrade then
+ Vlayer.insert_item(modded.base_game_equivalent, stored * modded.multiplier)
+ elseif vlayer_data.storage.power_items[name] then
+ local power_item = vlayer_data.storage.power_items[name]
+ power_item.count = power_item.count + stored
+ else
+ Vlayer.insert_item(name, stored)
+ end
+ inventory.remove{ name = name, count = removed, quality = item.quality }
+ end
+ end
+ end
+ end
+end
+
+--- Fill the requests of the output interfaces from storage
+local function handle_output_interfaces()
+ local interfaces = vlayer_data.interfaces.storage_output
+ prune_interfaces(interfaces)
+ for _, interface in ipairs(interfaces) do
+ local inventory = assert(interface.get_inventory(defines.inventory.chest))
+ for _, section in ipairs(assert(interface.get_logistic_sections()).sections) do
+ for _, filter in pairs(section.filters) do
+ local name = filter.value and filter.value.name
+ if name and allowed_items[name] then
+ local wanted = min(assert(filter.min) - inventory.get_item_count(name), vlayer_data.storage.items[name])
+ if wanted > 0 and inventory.can_insert{ name = name, count = wanted } then
+ local removed = Vlayer.remove_item(name, wanted)
+ if removed > 0 then
+ inventory.insert{ name = name, count = removed, quality = "normal" }
+ end
+ end
+ end
+ end
+ end
+ end
+end
+
+--- Allocate waiting items in proportion to the surface area that has become available
+local function handle_unallocated()
+ if config.unlimited_surface_area then return end
+
+ local unallocated_area = 0
+ for name, count in pairs(vlayer_data.storage.unallocated) do
+ unallocated_area = unallocated_area + allowed_items[name].required_area * count
+ end
+ if unallocated_area == 0 then return end
+
+ local properties = vlayer_data.properties
+ local surplus_area = properties.total_surface_area - properties.used_surface_area
+ for name, count in pairs(vlayer_data.storage.unallocated) do
+ local allocated = min(count, floor(count * surplus_area / unallocated_area))
+ if allocated > 0 then
+ vlayer_data.storage.unallocated[name] = count - allocated
+ allocate_item(name, allocated)
+ count_unallocated_production(name, -allocated)
+ end
+ end
+end
+
+--- Set the signals of the circuit interfaces to the statistics and stored items
+local function handle_circuit_interfaces()
+ local interfaces = vlayer_data.interfaces.circuit
+ prune_interfaces(interfaces)
+ if #interfaces == 0 then return end
+
+ local stats = Vlayer.get_statistics()
+ for _, interface in ipairs(interfaces) do
+ local control = interface.get_or_create_control_behavior() --[[@as LuaConstantCombinatorControlBehavior]]
+ if control.sections_count == 0 then
+ control.add_section()
+ end
+ local section = assert(control.sections[1])
+
+ -- Slots are cleared first because an item can not be set in two slots at once
+ for index = section.filters_count, 1, -1 do
+ section.clear_slot(index)
+ end
+
+ local index = 1
+ for stat_name, signal_name in pairs(circuit_signals) do
+ local value = stats[stat_name] --[[@as number]]
+ if stat_name:find("energy") then
+ value = value / mega
+ elseif stat_name == "production_multiplier" then
+ value = value * 10000
+ end
+ section.set_slot(index, { value = { type = "virtual", name = signal_name, quality = "normal" }, min = floor(value) })
+ index = index + 1
+ end
+
+ for name, count in pairs(vlayer_data.storage.items) do
+ if count > 0 and prototypes.item[name] then
+ section.set_slot(index, { value = { type = "item", name = name, quality = "normal" }, min = count })
+ index = index + 1
+ end
+ end
+ end
+end
+
+--- Produce energy, share it between the energy interfaces, and burn fuel when storage runs low
+local function handle_energy_interfaces()
+ local storage = vlayer_data.storage
+ local properties = vlayer_data.properties
+ local production = properties.production * mega * (config.update_tick_energy / 60)
+ storage.energy = storage.energy + floor(production * get_production_multiplier())
+
+ local interfaces = vlayer_data.interfaces.energy
+ prune_interfaces(interfaces)
+ if #interfaces > 0 then
+ local available_energy = storage.energy
+ for _, interface in ipairs(interfaces) do
+ available_energy = available_energy + interface.energy
+ end
+
+ local discharge_rate = 2 * (production + properties.discharge * mega) / #interfaces
+ local fill_to = min(discharge_rate, floor(available_energy / #interfaces))
+ for _, interface in ipairs(interfaces) do
+ interface.electric_buffer_size = max(discharge_rate, interface.energy) -- A smaller buffer would lose energy
+ local delta = fill_to - interface.energy
+ storage.energy = storage.energy - delta
+ interface.energy = interface.energy + delta
+ end
+ end
+
+ local capacity = properties.capacity * mega
+ if not config.unlimited_capacity and storage.energy > capacity then
+ storage.energy = capacity
+ return
+ end
+
+ for _, power_item in pairs(storage.power_items) do
+ local max_burn = capacity / 2 - storage.energy
+ if power_item.count > 0 and max_burn > 0 then
+ local burn = min(power_item.count, floor(max_burn / power_item.value))
+ storage.energy = storage.energy + burn * power_item.value
+ power_item.count = power_item.count - burn
+ end
+ end
+end
+
+--- Take the day cycle from the mimic surface when it exists, otherwise from the config
+local function update_surface()
+ if config.mimic_surface then
+ local surface = game.get_surface(config.mimic_surface)
+ if surface then
+ vlayer_data.surface = surface
+ return
+ end
+ end
+
+ -- The config copy never has an index, so this avoids replacing it every time a surface changes
+ if not vlayer_data.surface.index then
+ vlayer_data.surface = table.deep_copy(config.surface)
+ end
+end
+
+--- Move items between the interfaces and storage
+local function update_storage()
+ handle_input_interfaces()
+ handle_output_interfaces()
+ handle_unallocated()
+end
+
+--- Update the signals and energy of the interfaces
+local function update_energy()
+ handle_circuit_interfaces()
+ handle_energy_interfaces()
+end
+
+local e = defines.events
+
+Vlayer.on_init = update_surface -- The default surface exists before on_surface_created can fire
+Vlayer.events[e.on_surface_created] = update_surface
+Vlayer.events[e.on_surface_renamed] = update_surface
+Vlayer.events[e.on_surface_imported] = update_surface
+Vlayer.on_nth_tick[config.update_tick_storage] = update_storage
+Vlayer.on_nth_tick[config.update_tick_energy] = update_energy
+
+return Vlayer
diff --git a/exp_scenario/module/gui/player_bonus.lua b/exp_scenario/module/gui/player_bonus.lua
index 13317530..1fadeccc 100644
--- a/exp_scenario/module/gui/player_bonus.lua
+++ b/exp_scenario/module/gui/player_bonus.lua
@@ -5,7 +5,7 @@ Adds a gui that allows players to apply various bonuses
local Gui = require("modules/exp_gui")
local Roles = require("modules/exp_roles")
local config = require("modules/exp_legacy/config/bonus")
-local vlayer = require("modules/exp_legacy/modules/control/vlayer")
+local Vlayer = require("modules/exp_scenario/control/vlayer")
local format_number = require("util").format_number
--- @class ExpGui_PlayerBonus.elements
@@ -486,7 +486,7 @@ end
--- Apply periodic bonus to a player
--- @param player LuaPlayer
local function apply_personal_battery_recharge(player)
- local available_energy = vlayer.get_statistics()["energy_storage"]
+ local available_energy = Vlayer.get_statistics().energy_storage
if available_energy <= 0 then
return -- No power to give
end
@@ -508,7 +508,7 @@ local function apply_personal_battery_recharge(player)
local energy_to_give = math.min(math.floor(equipment.max_energy - equipment.energy), available_energy, recharge_amount)
equipment.energy = equipment.energy + energy_to_give
recharge_amount = recharge_amount - energy_to_give
- available_energy = vlayer.energy_changed(-energy_to_give)
+ available_energy = Vlayer.energy_changed(-energy_to_give)
end
end
end
diff --git a/exp_scenario/module/gui/player_stats.lua b/exp_scenario/module/gui/player_stats.lua
index 5740aa2a..a0deea41 100644
--- a/exp_scenario/module/gui/player_stats.lua
+++ b/exp_scenario/module/gui/player_stats.lua
@@ -117,7 +117,7 @@ Elements.player_stats_table = Gui.define("player_stats/data_table")
width = 135,
})
labels[stat_name] = Elements.table_label(data_table, {
- caption = { "readme.data-format", value, metadata.unit or "" },
+ caption = { "exp-gui_readme.data-format", value, metadata.unit or "" },
tooltip = metadata.value_tooltip or { "exp-statistics." .. stat_name .. "-tooltip" },
width = 105,
})
@@ -131,7 +131,7 @@ Elements.player_stats_table = Gui.define("player_stats/data_table")
width = 135,
})
labels[stat_name] = Elements.table_label(data_table, {
- caption = { "readme.data-format", data.default, "" },
+ caption = { "exp-gui_readme.data-format", data.default, "" },
tooltip = { "exp-statistics." .. stat_name .. "-tooltip" },
width = 105,
})
diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua
index 1503ee9b..b8a7bcc6 100644
--- a/exp_scenario/module/gui/readme.lua
+++ b/exp_scenario/module/gui/readme.lua
@@ -189,7 +189,7 @@ define_tab(
local player = Gui.get_player(parent)
local server_details = {
- name = "ExpGaming S0 - Local",
+ name = config_server_detail["community_name"] .. " S0 - Local",
welcome = "Failed to load description: disconnected from external api.",
reset_time = "Not Set",
}
@@ -338,7 +338,7 @@ define_tab(
local external_links = Elements.title_table(scroll_pane, 235, { "exp-gui_readme.servers-external" }, 2)
for _, key in ipairs{ "discord", "website", "patreon", "status", "github" } do
Gui.elements.centered_label(external_links, 110, key:gsub("^%l", string.upper))
- Gui.elements.centered_label(external_links, 460, { "links." .. key }, { "exp-gui_readme.servers-open-in-browser" })
+ Gui.elements.centered_label(external_links, 460, config_server_detail[key], { "exp-gui_readme.servers-open-in-browser" })
end
return container
diff --git a/exp_scenario/module/gui/vlayer.lua b/exp_scenario/module/gui/vlayer.lua
new file mode 100644
index 00000000..b5bea951
--- /dev/null
+++ b/exp_scenario/module/gui/vlayer.lua
@@ -0,0 +1,531 @@
+--[[-- Gui - Virtual Layer
+Shows what the virtual layer holds and lets players build and remove its interfaces
+]]
+
+local Gui = require("modules/exp_gui")
+local Roles = require("modules/exp_roles")
+local Colors = require("modules/exp_util/include/color")
+local config = require("modules.exp_legacy.config.vlayer")
+local Vlayer = require("modules/exp_scenario/control/vlayer")
+local format_number = require("util").format_number
+
+local Selection = require("modules/exp_util/selection")
+local SelectArea = Selection.connect("ExpGui_Vlayer")
+
+local floor = math.floor
+local ceil = math.ceil
+local max = math.max
+local format_string = string.format
+
+--- @class ExpGui_Vlayer.elements
+local Elements = {}
+
+--- Interface types in the order the type dropdown lists them
+local interface_types = { "energy", "circuit", "storage_input", "storage_output" } --- @type ExpScenario_Vlayer.InterfaceType[]
+
+--- Locale key of each interface type
+local type_captions = {} --- @type table
+for _, interface_type in ipairs(interface_types) do
+ type_captions[interface_type] = { "exp-gui_vlayer.type-" .. interface_type:gsub("_", "-") }
+end
+
+--- Statistics shown as progress bars, in display order
+--- @type { name: string, caption: LocalisedString, tooltip: LocalisedString }[]
+local stats = {}
+for _, name in ipairs{ "solar", "accumulator", "surface-area", "sustained", "production", "capacity" } do
+ stats[#stats + 1] = {
+ name = name,
+ caption = { "exp-gui_vlayer.caption-stat-" .. name },
+ tooltip = { "exp-gui_vlayer.tooltip-stat-" .. name },
+ }
+end
+
+local energy_suffixes = {
+ { 1e15, "P" }, { 1e12, "T" }, { 1e9, "G" }, { 1e6, "M" }, { 1e3, "k" },
+}
+
+--- Format an amount of energy with an SI prefix, util.format_number uses B for giga
+--- @param amount number
+--- @param unit string
+--- @return string
+local function format_energy(amount, unit)
+ if amount < 1 then
+ return "0 " .. unit
+ end
+ for _, suffix in ipairs(energy_suffixes) do
+ if amount >= suffix[1] then
+ return format_string("%.1f %s%s", amount / suffix[1], suffix[2], unit)
+ end
+ end
+ return format_number(floor(amount), false) .. " " .. unit
+end
+
+--- Expand an area to whole tiles
+--- @param area BoundingBox
+--- @return BoundingBox
+local function expand_area(area)
+ return {
+ left_top = { x = floor(area.left_top.x), y = floor(area.left_top.y) },
+ right_bottom = { x = ceil(area.right_bottom.x), y = ceil(area.right_bottom.y) },
+ }
+end
+
+--- Rich text link to a position
+--- @param position MapPosition
+--- @param surface LuaSurface
+--- @return string
+local function gps_string(position, surface)
+ return format_string("[gps=%.1f,%.1f,%s]", position.x, position.y, surface.name)
+end
+
+--- Label naming a statistic
+--- @class ExpGui_Vlayer.elements.stat_label: ExpElement
+--- @overload fun(parent: LuaGuiElement, caption: LocalisedString, tooltip: LocalisedString): LuaGuiElement
+Elements.stat_label = Gui.define("vlayer/stat_label")
+ :draw{
+ type = "label",
+ caption = Gui.from_argument(1),
+ tooltip = Gui.from_argument(2),
+ style = "heading_2_label",
+ }
+ :style{
+ width = 200,
+ } --[[@as any]]
+
+--- Bar showing a statistic as a fraction with the values as its caption
+--- @class ExpGui_Vlayer.elements.stat_bar: ExpElement
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.stat_bar = Gui.define("vlayer/stat_bar")
+ :draw{
+ type = "progressbar",
+ caption = "",
+ value = 0,
+ style = "electric_satisfaction_statistics_progressbar",
+ }
+ :style{
+ width = 200,
+ font = "heading-2",
+ } --[[@as any]]
+
+--- @class ExpGui_Vlayer.elements.stat_bar.display_data
+--- @field value number Fraction the bar is filled
+--- @field caption string
+
+--- Refresh a bar
+--- @param stat_bar LuaGuiElement
+--- @param display_data ExpGui_Vlayer.elements.stat_bar.display_data
+function Elements.stat_bar.refresh(stat_bar, display_data)
+ stat_bar.value = display_data.value
+ stat_bar.caption = display_data.caption
+end
+
+--- Table of a label and bar for each statistic
+--- @class ExpGui_Vlayer.elements.stats_table: ExpElement
+--- @field data table> Bars keyed by statistic name
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.stats_table = Gui.define("vlayer/stats_table")
+ :track_all_elements()
+ :draw(function(def, parent)
+ --- @cast def ExpGui_Vlayer.elements.stats_table
+ local stats_table = Gui.elements.scroll_table(parent, 400, 2, "stats")
+ local stat_bars = {}
+ for _, stat in ipairs(stats) do
+ Elements.stat_label(stats_table, stat.caption, stat.tooltip)
+ stat_bars[stat.name] = Elements.stat_bar(stats_table)
+ end
+ def.data[stats_table] = stat_bars
+ return stats_table
+ end) --[[@as any]]
+
+--- Calculate the display data of every statistic, the same for every player
+--- @return table
+function Elements.stats_table.calculate_display_data()
+ local statistics = Vlayer.get_statistics()
+ local items = Vlayer.get_items()
+ local allocated = Vlayer.get_allocated_items()
+ return {
+ solar = {
+ value = allocated["solar-panel"] / max(items["solar-panel"], 1),
+ caption = format_number(allocated["solar-panel"], false) .. " / " .. format_number(items["solar-panel"], false),
+ },
+ accumulator = {
+ value = allocated["accumulator"] / max(items["accumulator"], 1),
+ caption = format_number(allocated["accumulator"], false) .. " / " .. format_number(items["accumulator"], false),
+ },
+ ["surface-area"] = {
+ value = statistics.total_surface_area / max(statistics.surface_area, 1),
+ caption = format_number(statistics.remaining_surface_area, false),
+ },
+ sustained = {
+ value = statistics.energy_sustained / max(statistics.energy_total_production, 1),
+ caption = format_energy(statistics.energy_sustained, "W") .. " / " .. format_energy(statistics.energy_total_production, "W"),
+ },
+ production = {
+ value = statistics.energy_production / max(statistics.energy_max, 1),
+ caption = format_energy(statistics.energy_production, "W") .. " / " .. format_energy(statistics.energy_max, "W"),
+ },
+ capacity = {
+ value = statistics.energy_storage / max(statistics.energy_capacity, 1),
+ caption = format_energy(statistics.energy_storage, "J") .. " / " .. format_energy(statistics.energy_capacity, "J"),
+ },
+ }
+end
+
+--- Refresh every bar of a table
+--- @param stats_table LuaGuiElement
+--- @param display_data table
+function Elements.stats_table.refresh(stats_table, display_data)
+ for name, stat_bar in pairs(Elements.stats_table.data[stats_table]) do
+ Elements.stat_bar.refresh(stat_bar, display_data[name])
+ end
+end
+
+--- Refresh the tables of every online player
+function Elements.stats_table.refresh_online()
+ local display_data = Elements.stats_table.calculate_display_data()
+ for _, stats_table in Elements.stats_table:online_elements() do
+ Elements.stats_table.refresh(stats_table, display_data)
+ end
+end
+
+--- @class ExpGui_Vlayer.elements.type_dropdown.element_data
+--- @field controls LuaGuiElement
+
+--- Dropdown which picks the type of interface to build or list
+--- @class ExpGui_Vlayer.elements.type_dropdown: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, controls: LuaGuiElement): LuaGuiElement
+Elements.type_dropdown = Gui.define("vlayer/type_dropdown")
+ :draw{
+ type = "drop-down",
+ items = { type_captions.energy, type_captions.circuit, type_captions.storage_input, type_captions.storage_output },
+ selected_index = 1,
+ }
+ :style{
+ width = 200,
+ }
+ :element_data{
+ controls = Gui.from_argument(1),
+ }
+ :on_selection_state_changed(function(def, _, type_dropdown)
+ --- @cast def ExpGui_Vlayer.elements.type_dropdown
+ Elements.controls.refresh_interfaces(def.data[type_dropdown].controls)
+ end) --[[@as any]]
+
+--- Get the selected interface type
+--- @param type_dropdown LuaGuiElement
+--- @return ExpScenario_Vlayer.InterfaceType
+function Elements.type_dropdown.get_selected(type_dropdown)
+ return (assert(interface_types[type_dropdown.selected_index]))
+end
+
+--- Dropdown listing the interfaces of the selected type by position
+--- @class ExpGui_Vlayer.elements.interface_dropdown: ExpElement
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.interface_dropdown = Gui.define("vlayer/interface_dropdown")
+ :draw{
+ type = "drop-down",
+ }
+ :style{
+ width = 200,
+ } --[[@as any]]
+
+--- Refresh the listed interfaces
+--- @param interface_dropdown LuaGuiElement
+--- @param interfaces LuaEntity[]
+function Elements.interface_dropdown.refresh(interface_dropdown, interfaces)
+ local items = {}
+ for index, interface in ipairs(interfaces) do
+ local position = interface.position
+ items[index] = format_string("%d X %.1f Y %.1f", index, position.x, position.y)
+ end
+ interface_dropdown.items = items
+end
+
+--- Get the selected interface
+--- @param interface_dropdown LuaGuiElement
+--- @param interfaces LuaEntity[]
+--- @return LuaEntity?
+function Elements.interface_dropdown.get_selected(interface_dropdown, interfaces)
+ local interface = interfaces[interface_dropdown.selected_index]
+ if interface and interface.valid then
+ return interface
+ end
+ return nil
+end
+
+--- @class ExpGui_Vlayer.elements.control_button.element_data
+--- @field controls LuaGuiElement
+
+--- Button which refreshes the listed interfaces
+--- @class ExpGui_Vlayer.elements.refresh_button: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, controls: LuaGuiElement): LuaGuiElement
+Elements.refresh_button = Gui.define("vlayer/refresh_button")
+ :draw{
+ type = "button",
+ caption = { "exp-gui_vlayer.caption-refresh" },
+ }
+ :style{
+ width = 200,
+ }
+ :element_data{
+ controls = Gui.from_argument(1),
+ }
+ :on_click(function(def, _, refresh_button)
+ --- @cast def ExpGui_Vlayer.elements.refresh_button
+ Elements.controls.refresh_interfaces(def.data[refresh_button].controls)
+ end) --[[@as any]]
+
+--- Button which opens the map at the selected interface
+--- @class ExpGui_Vlayer.elements.view_button: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, controls: LuaGuiElement): LuaGuiElement
+Elements.view_button = Gui.define("vlayer/view_button")
+ :draw{
+ type = "button",
+ caption = { "exp-gui_vlayer.caption-view" },
+ }
+ :style{
+ width = 200,
+ }
+ :element_data{
+ controls = Gui.from_argument(1),
+ }
+ :on_click(function(def, player, view_button)
+ --- @cast def ExpGui_Vlayer.elements.view_button
+ local controls = def.data[view_button].controls
+ local interface_type, interface = Elements.controls.get_selected_interface(controls)
+ if not interface then return end
+ player.set_controller{ type = defines.controllers.remote, position = interface.position, surface = interface.surface }
+ player.print{ "exp-gui_vlayer.message-located", type_captions[interface_type], gps_string(interface.position, interface.surface) }
+ end) --[[@as any]]
+
+--- Button which toggles selecting an area to build an interface in
+--- @class ExpGui_Vlayer.elements.build_button: ExpElement
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.build_button = Gui.define("vlayer/build_button")
+ :track_all_elements()
+ :draw{
+ type = "button",
+ caption = { "exp-gui_vlayer.caption-build" },
+ }
+ :style{
+ width = 200,
+ }
+ :on_click(function(_, player)
+ if SelectArea:stop(player) then
+ player.print{ "exp_util.selection_exit", { "exp-gui_vlayer.selection-name" } }
+ else
+ SelectArea:start(player)
+ player.print{ "exp_util.selection_enter", { "exp-gui_vlayer.selection-name" } }
+ end
+ end) --[[@as any]]
+
+--- Button which removes the selected interface
+--- @class ExpGui_Vlayer.elements.remove_button: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, controls: LuaGuiElement): LuaGuiElement
+Elements.remove_button = Gui.define("vlayer/remove_button")
+ :track_all_elements()
+ :draw{
+ type = "button",
+ caption = { "exp-gui_vlayer.caption-remove" },
+ }
+ :style{
+ width = 200,
+ }
+ :element_data{
+ controls = Gui.from_argument(1),
+ }
+ :on_click(function(def, player, remove_button)
+ --- @cast def ExpGui_Vlayer.elements.remove_button
+ local controls = def.data[remove_button].controls
+ local _, interface = Elements.controls.get_selected_interface(controls)
+ if not interface then return end
+ local position, surface = interface.position, interface.surface
+ local interface_type = Vlayer.remove_interface(interface)
+ game.print{ "exp-gui_vlayer.message-removed", player.name, type_captions[interface_type], gps_string(position, surface) }
+ Elements.controls.refresh_interfaces_online()
+ end) --[[@as any]]
+
+--- @class ExpGui_Vlayer.elements.controls.element_data
+--- @field type_dropdown LuaGuiElement
+--- @field interface_dropdown LuaGuiElement
+
+--- Table of the dropdowns and buttons used to manage interfaces
+--- @class ExpGui_Vlayer.elements.controls: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.controls = Gui.define("vlayer/controls")
+ :track_all_elements()
+ :draw(function(def, parent)
+ --- @cast def ExpGui_Vlayer.elements.controls
+ local controls = Gui.elements.scroll_table(parent, 400, 2, "controls")
+ local player = Gui.get_player(parent)
+ local can_edit = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit")
+
+ local type_dropdown = Elements.type_dropdown(controls, controls)
+ local interface_dropdown = Elements.interface_dropdown(controls)
+ Elements.refresh_button(controls, controls)
+ Elements.view_button(controls, controls)
+ Elements.build_button(controls).visible = can_edit
+ Elements.remove_button(controls, controls).visible = can_edit
+
+ def.data[controls] = {
+ type_dropdown = type_dropdown,
+ interface_dropdown = interface_dropdown,
+ }
+ Elements.controls.refresh_interfaces(controls)
+ return controls
+ end) --[[@as any]]
+
+--- Get the selected interface type
+--- @param controls LuaGuiElement
+--- @return ExpScenario_Vlayer.InterfaceType
+function Elements.controls.get_selected_type(controls)
+ return Elements.type_dropdown.get_selected(Elements.controls.data[controls].type_dropdown)
+end
+
+--- Get the selected interface and its type
+--- @param controls LuaGuiElement
+--- @return ExpScenario_Vlayer.InterfaceType, LuaEntity?
+function Elements.controls.get_selected_interface(controls)
+ local elements = Elements.controls.data[controls]
+ local interface_type = Elements.type_dropdown.get_selected(elements.type_dropdown)
+ local interfaces = Vlayer.get_interfaces()[interface_type]
+ return interface_type, Elements.interface_dropdown.get_selected(elements.interface_dropdown, interfaces)
+end
+
+--- Refresh the listed interfaces to match the selected type
+--- @param controls LuaGuiElement
+function Elements.controls.refresh_interfaces(controls)
+ local elements = Elements.controls.data[controls]
+ local interface_type = Elements.type_dropdown.get_selected(elements.type_dropdown)
+ Elements.interface_dropdown.refresh(elements.interface_dropdown, Vlayer.get_interfaces()[interface_type])
+end
+
+--- Refresh the listed interfaces of every online player, used when an interface is built or removed
+function Elements.controls.refresh_interfaces_online()
+ for _, controls in Elements.controls:online_elements() do
+ Elements.controls.refresh_interfaces(controls)
+ end
+end
+
+--- Refresh whether a player can see the edit buttons
+--- @param player LuaPlayer
+function Elements.controls.refresh_permissions(player)
+ local can_edit = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit")
+ for _, build_button in Elements.build_button:tracked_elements(player) do
+ build_button.visible = can_edit
+ end
+ for _, remove_button in Elements.remove_button:tracked_elements(player) do
+ remove_button.visible = can_edit
+ end
+end
+
+--- Container added to the left gui flow
+--- @class ExpGui_Vlayer.elements.container: ExpElement
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.container = Gui.define("vlayer/container")
+ :draw(function(_, parent)
+ local container = Gui.elements.container(parent, 400)
+ local stats_table = Elements.stats_table(container)
+ Elements.stats_table.refresh(stats_table, Elements.stats_table.calculate_display_data())
+ Elements.controls(container)
+ return Gui.elements.container.get_root_element(container)
+ end) --[[@as any]]
+
+--- Add the element to the left flow with a toolbar button
+Gui.add_left_element(Elements.container, false)
+Gui.toolbar.create_button{
+ name = "toggle_vlayer",
+ left_element = Elements.container,
+ sprite = "entity/solar-panel",
+ tooltip = { "exp-gui_vlayer.tooltip-main" },
+ visible = function(player, element)
+ return Roles.player_has_permission(player, "exp_scenario.gui.vlayer")
+ end
+}
+
+--- Replace the one marker entity in a selected area with an interface of the type the player picked
+--- @param event EventData.on_player_selected_area
+local function on_area_selected(event)
+ local player = Gui.get_player(event)
+ local surface = event.surface
+ local area = expand_area(event.area)
+ local force = player.force --[[@as LuaForce]]
+
+ local interface_type = "energy" --- @type ExpScenario_Vlayer.InterfaceType
+ for _, controls in Elements.controls:tracked_elements(player) do
+ interface_type = Elements.controls.get_selected_type(controls)
+ end
+
+ -- Platforms have no chests, so a combinator marks the spot once the research allows it
+ local marker_name = "steel-chest"
+ if config.power_on_space and surface.platform and interface_type == "energy" then
+ local research = config.power_on_space_research
+ if force.technologies[research.name].level < research.level then
+ player.print({ "exp-gui_vlayer.message-research", research.name, research.level }, Colors.orange_red)
+ return
+ end
+ marker_name = "constant-combinator"
+ end
+
+ local markers = surface.find_entities_filtered{ area = area, name = marker_name, force = force }
+ if #markers == 0 then
+ player.print({ "exp-gui_vlayer.message-no-chest" }, Colors.orange_red)
+ return
+ elseif #markers > 1 then
+ player.print({ "exp-gui_vlayer.message-multiple", type_captions[interface_type] }, Colors.orange_red)
+ return
+ end
+
+ local marker = assert(markers[1])
+ if marker.name == "steel-chest" and not assert(marker.get_inventory(defines.inventory.chest)).is_empty() then
+ player.print({ "exp-gui_vlayer.message-chest-not-empty" }, Colors.orange_red)
+ return
+ end
+
+ if #Vlayer.get_interfaces()[interface_type] >= config.interface_limit[interface_type] then
+ player.print({ "exp-gui_vlayer.message-limit", type_captions[interface_type] }, Colors.orange_red)
+ return
+ end
+
+ local position = marker.position
+ marker.destroy()
+
+ if interface_type == "energy" then
+ if not Vlayer.create_energy_interface(surface, position, player) then
+ player.print({ "exp-gui_vlayer.message-no-space" }, Colors.orange_red)
+ return
+ end
+ elseif interface_type == "circuit" then
+ Vlayer.create_circuit_interface(surface, position, player)
+ elseif interface_type == "storage_input" then
+ Vlayer.create_input_interface(surface, position, player)
+ else
+ Vlayer.create_output_interface(surface, position, player)
+ end
+
+ game.print{ "exp-gui_vlayer.message-built", player.name, type_captions[interface_type], gps_string(position, surface) }
+ Elements.controls.refresh_interfaces_online()
+end
+
+SelectArea:on_selection(on_area_selected)
+
+--- Refresh the edit buttons as the permissions of a player may have changed
+--- @param event EventData.ExpRoles.on_player_roles_changed
+local function refresh_player_permissions(event)
+ Elements.controls.refresh_permissions(Gui.get_player(event))
+end
+
+return {
+ elements = Elements,
+ events = {
+ [Roles.events.on_player_roles_changed] = refresh_player_permissions,
+ },
+ on_nth_tick = {
+ [config.update_tick_gui] = Elements.stats_table.refresh_online,
+ }
+}
diff --git a/exp_scenario/module/gui/warp_list.lua b/exp_scenario/module/gui/warp_list.lua
new file mode 100644
index 00000000..f76faed4
--- /dev/null
+++ b/exp_scenario/module/gui/warp_list.lua
@@ -0,0 +1,1218 @@
+--[[-- Gui - Warp List
+Adds a list of warp points which players can travel between, add, and edit
+]]
+
+local Gui = require("modules/exp_gui")
+local Roles = require("modules/exp_roles")
+local Colors = require("modules/exp_util/include/color")
+local config = require("modules.exp_legacy.config.gui.warps")
+
+local ExpUtil = require("modules/exp_util")
+local format_time = ExpUtil.format_time_factory_locale{ format = "short", hours = true, minutes = true }
+
+local floor = math.floor
+local ceil = math.ceil
+
+--- @class ExpGui_WarpList.elements
+local Elements = {}
+
+local Styles = {
+ sprite22 = { height = 22, width = 22, padding = -2 },
+ sprite32 = { height = 32, width = 32, left_margin = 1 },
+}
+
+--- Rich text icons shown next to a warp name to describe why it can or can not be used
+local status_icons = {
+ cooldown = "[img=utility/multiplayer_waiting_icon]",
+ not_available = "[img=utility/set_bar_slot]",
+ bypass = "[img=utility/side_menu_bonus_icon]",
+ current = "[img=utility/side_menu_map_icon]",
+ connected = "[img=utility/logistic_network_panel_white]",
+ different = "[img=utility/warning_white]",
+}
+
+local update_interval = floor(60 / config.update_smoothing)
+local cooldown_ticks = config.cooldown_duration * 60
+local proximity_radius_sq = config.standard_proximity_radius ^ 2
+local spawn_radius_sq = config.spawn_proximity_radius ^ 2
+local minimum_distance_sq = config.minimum_distance ^ 2
+
+--- Names of the entities which make up a warp area, used to find them when the area is removed
+local area_entity_names = {} --- @type string[]
+for _, entity in ipairs(config.entities) do
+ area_entity_names[#area_entity_names + 1] = entity[1]
+end
+
+--- @class ExpGui_WarpList.Warp
+--- @field id number
+--- @field force LuaForce
+--- @field name string
+--- @field icon SignalID
+--- @field surface LuaSurface
+--- @field position MapPosition.struct The tile the warp is centred on
+--- @field last_user LuaPlayer? Nil for warps made by the scenario
+--- @field last_edit_tick number
+--- @field editing table Names of the players editing the warp keyed by their index
+--- @field tag LuaCustomChartTag?
+--- @field electric_pole LuaEntity? Pole within the warp area, warps on the same network can be travelled between
+--- @field old_tile string? The tile the warp area replaced, nil when no area was made
+--- @field new boolean? True until the first edit is confirmed, discarding that edit removes the warp
+
+--- @class ExpGui_WarpList.PlayerStatus
+--- @field in_range ExpGui_WarpList.Warp? The warp the player is standing on
+--- @field on_cooldown boolean
+--- @field bypass_proximity boolean
+
+--- Check a permission from the config, which says who an action applies to
+--- @param player LuaPlayer
+--- @param action "bypass_warp_cooldown" | "bypass_warp_proximity" | "allow_add_warp" | "allow_edit_warp"
+--- @return boolean
+local function has_config_permission(player, action)
+ local setting = config[action]
+ if setting == "all" then
+ return true
+ elseif setting == "admin" then
+ return player.admin
+ elseif setting == "exp_roles" then
+ return Roles.player_has_permission(player, config["exp_roles_" .. action])
+ end
+
+ return false
+end
+
+--- Check if a player can edit a warp, the spawn warp can never be edited
+--- @param player LuaPlayer
+--- @param warp ExpGui_WarpList.Warp
+--- @return boolean
+local function has_permission_edit_warp(player, warp)
+ if Elements.container.get_spawn_warp(warp.force) == warp then
+ return false
+ end
+
+ if config.user_can_edit_own_warps and warp.last_user == player then
+ return true
+ end
+
+ return has_config_permission(player, "allow_edit_warp")
+end
+
+--- The sprite path for a warp icon, signal ids and sprite paths name virtual signals differently
+--- @param icon SignalID
+--- @return SpritePath
+local function icon_sprite(icon)
+ if icon.type == "virtual" then
+ return "virtual-signal/" .. icon.name
+ end
+ return (icon.type or "item") .. "/" .. icon.name
+end
+
+--- Draw a cross over something that stops a warp being added
+--- @param surface LuaSurface
+--- @param player LuaPlayer
+--- @param target LuaEntity | MapPosition
+local function draw_blocker(surface, player, target)
+ rendering.draw_sprite{
+ sprite = "utility/rail_path_not_possible",
+ x_scale = 0.5,
+ y_scale = 0.5,
+ target = target,
+ surface = surface,
+ players = { player },
+ time_to_live = 60,
+ }
+end
+
+--- Button in the header which adds a warp at the player
+--- @class ExpGui_WarpList.elements.add_warp_button: ExpElement
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.add_warp_button = Gui.define("warp_list/add_warp_button")
+ :track_all_elements()
+ :draw{
+ type = "sprite-button",
+ sprite = "utility/add",
+ tooltip = { "exp-gui_warp-list.tooltip-add" },
+ style = "shortcut_bar_button",
+ }
+ :style(Styles.sprite22)
+ :on_click(function(_, player)
+ if player.controller_type ~= defines.controllers.character then return end
+ local surface = player.physical_surface
+ local position = player.physical_position
+
+ local water_tiles = surface.find_tiles_filtered{
+ collision_mask = "water_tile",
+ radius = config.standard_proximity_radius + 1,
+ position = position,
+ }
+ if #water_tiles > 0 then
+ player.print({ "exp-gui_warp-list.add-warning-water", config.standard_proximity_radius + 1 }, Colors.orange_red)
+ player.play_sound{ path = "utility/wire_pickup" }
+ for _, tile in pairs(water_tiles) do
+ draw_blocker(surface, player, tile.position)
+ end
+ return
+ end
+
+ -- A larger radius than the area because the entities a player can place are larger than tiles
+ local entities = surface.find_entities_filtered{
+ radius = config.standard_proximity_radius + 2.5,
+ position = position,
+ collision_mask = { "item", "object", "player", "water_tile" },
+ }
+ if #entities > 1 then -- The player's own character is always found
+ player.print({ "exp-gui_warp-list.add-warning-entities", config.standard_proximity_radius + 2.5 }, Colors.orange_red)
+ player.play_sound{ path = "utility/wire_pickup" }
+ local character = player.character
+ for _, entity in pairs(entities) do
+ if entity ~= character then
+ draw_blocker(surface, player, entity)
+ end
+ end
+ return
+ end
+
+ local force = player.force --[[@as LuaForce]]
+ local warp = Elements.container.add_warp(force, surface, position, player)
+ Elements.container.set_editing(warp, player, true)
+ end) --[[@as any]]
+
+--- Refresh whether the add button is enabled based on the closest warp
+--- @param player LuaPlayer
+--- @param blocking ExpGui_WarpList.Warp? The warp too close to add another, nil when a warp can be added
+function Elements.add_warp_button.refresh_player_blocking(player, blocking)
+ local tooltip = blocking and { "exp-gui_warp-list.tooltip-add-too-close", blocking.name } or { "exp-gui_warp-list.tooltip-add" }
+ for _, add_warp_button in Elements.add_warp_button:tracked_elements(player) do
+ add_warp_button.enabled = blocking == nil
+ add_warp_button.tooltip = tooltip
+ end
+end
+
+--- @class ExpGui_WarpList.elements.warp_button.element_data
+--- @field warp ExpGui_WarpList.Warp
+
+--- Button which travels to the warp, enabled when the player is allowed to use it
+--- @class ExpGui_WarpList.elements.warp_button: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
+Elements.warp_button = Gui.define("warp_list/warp_button")
+ :draw(function(_, parent, warp)
+ --- @cast warp ExpGui_WarpList.Warp
+ return parent.add{
+ type = "sprite-button",
+ sprite = icon_sprite(warp.icon),
+ style = "slot_button",
+ }
+ end)
+ :style(Styles.sprite32)
+ :element_data{
+ warp = Gui.from_argument(1),
+ }
+ :on_click(function(def, player, warp_button)
+ --- @cast def ExpGui_WarpList.elements.warp_button
+ Elements.container.teleport_player(player, def.data[warp_button].warp)
+ end) --[[@as any]]
+
+--- @class ExpGui_WarpList.elements.icon_picker.element_data
+--- @field warp ExpGui_WarpList.Warp
+
+--- Signal picker shown in place of the warp button while editing
+--- @class ExpGui_WarpList.elements.icon_picker: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
+Elements.icon_picker = Gui.define("warp_list/icon_picker")
+ :draw(function(_, parent, warp)
+ --- @cast warp ExpGui_WarpList.Warp
+ return parent.add{
+ type = "choose-elem-button",
+ elem_type = "signal",
+ signal = warp.icon,
+ tooltip = { "exp-gui_warp-list.tooltip-icon" },
+ }
+ end)
+ :style(Styles.sprite32)
+ :element_data{
+ warp = Gui.from_argument(1),
+ } --[[@as any]]
+
+--- Get the icon chosen in a picker, the game omits the type for items
+--- @param icon_picker LuaGuiElement
+--- @return SignalID
+function Elements.icon_picker.get_icon(icon_picker)
+ local icon = icon_picker.elem_value --[[@as SignalID?]]
+ if not icon then
+ return Elements.icon_picker.data[icon_picker].warp.icon
+ end
+ icon.type = icon.type or "item"
+ return icon
+end
+
+--- Icon describing whether the warp can be used
+--- @class ExpGui_WarpList.elements.status_label: ExpElement
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.status_label = Gui.define("warp_list/status_label")
+ :draw{
+ type = "label",
+ caption = status_icons.not_available,
+ }
+ :style{
+ top_padding = 1, -- Keeps the icon in place when the taller textfield is shown
+ single_line = false,
+ } --[[@as any]]
+
+--- @class ExpGui_WarpList.elements.name_label.element_data
+--- @field warp ExpGui_WarpList.Warp
+
+--- Warp name, clicking it opens the map at the warp
+--- @class ExpGui_WarpList.elements.name_label: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
+Elements.name_label = Gui.define("warp_list/name_label")
+ :draw(function(_, parent, warp)
+ --- @cast warp ExpGui_WarpList.Warp
+ return parent.add{
+ type = "label",
+ caption = warp.name,
+ }
+ end)
+ :style{
+ single_line = true,
+ left_padding = 2,
+ right_padding = 2,
+ horizontally_stretchable = true,
+ }
+ :element_data{
+ warp = Gui.from_argument(1),
+ }
+ :on_click(function(def, player, name_label)
+ --- @cast def ExpGui_WarpList.elements.name_label
+ local warp = def.data[name_label].warp
+ player.set_controller{ type = defines.controllers.remote, position = warp.position, surface = warp.surface }
+ end) --[[@as any]]
+
+--- Refresh the name and last edit of the label
+--- @param name_label LuaGuiElement
+--- @param warp ExpGui_WarpList.Warp
+function Elements.name_label.refresh(name_label, warp)
+ local last_user = warp.last_user and warp.last_user.name or ""
+ name_label.caption = warp.name
+ name_label.tooltip = { "exp-gui_warp-list.tooltip-last-edit", last_user, format_time(warp.last_edit_tick) }
+end
+
+--- @class ExpGui_WarpList.elements.name_field.element_data
+--- @field warp ExpGui_WarpList.Warp
+--- @field icon_picker LuaGuiElement
+
+--- Textfield shown in place of the name label while editing, confirming it saves the edit
+--- @class ExpGui_WarpList.elements.name_field: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp, icon_picker: LuaGuiElement): LuaGuiElement
+Elements.name_field = Gui.define("warp_list/name_field")
+ :draw(function(_, parent, warp)
+ --- @cast warp ExpGui_WarpList.Warp
+ return parent.add{
+ type = "textfield",
+ text = warp.name,
+ clear_and_focus_on_right_click = true,
+ }
+ end)
+ :style{
+ minimal_width = 10,
+ maximal_width = 300,
+ horizontally_squashable = true,
+ horizontally_stretchable = true,
+ height = 22,
+ padding = -2,
+ left_margin = 2,
+ right_margin = 2,
+ }
+ :element_data{
+ warp = Gui.from_argument(1),
+ icon_picker = Gui.from_argument(2),
+ }
+ :on_confirmed(function(def, player, name_field)
+ --- @cast def ExpGui_WarpList.elements.name_field
+ local elements = def.data[name_field]
+ local icon = Elements.icon_picker.get_icon(elements.icon_picker)
+ Elements.container.update_warp(elements.warp, name_field.text, icon, player)
+ end) --[[@as any]]
+
+--- @class ExpGui_WarpList.elements.confirm_edit_button.element_data
+--- @field warp ExpGui_WarpList.Warp
+--- @field name_field LuaGuiElement
+--- @field icon_picker LuaGuiElement
+
+--- Button which saves the name and icon being edited
+--- @class ExpGui_WarpList.elements.confirm_edit_button: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp, name_field: LuaGuiElement, icon_picker: LuaGuiElement): LuaGuiElement
+Elements.confirm_edit_button = Gui.define("warp_list/confirm_edit_button")
+ :draw{
+ type = "sprite-button",
+ sprite = "utility/confirm_slot",
+ tooltip = { "exp-gui_warp-list.tooltip-confirm" },
+ style = "shortcut_bar_button_green",
+ }
+ :style(Styles.sprite22)
+ :element_data{
+ warp = Gui.from_argument(1),
+ name_field = Gui.from_argument(2),
+ icon_picker = Gui.from_argument(3),
+ }
+ :on_click(function(def, player, confirm_edit_button)
+ --- @cast def ExpGui_WarpList.elements.confirm_edit_button
+ local elements = def.data[confirm_edit_button]
+ local icon = Elements.icon_picker.get_icon(elements.icon_picker)
+ Elements.container.update_warp(elements.warp, elements.name_field.text, icon, player)
+ end) --[[@as any]]
+
+--- @class ExpGui_WarpList.elements.discard_edit_button.element_data
+--- @field warp ExpGui_WarpList.Warp
+
+--- Button which discards an edit, a warp which was never confirmed is removed
+--- @class ExpGui_WarpList.elements.discard_edit_button: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
+Elements.discard_edit_button = Gui.define("warp_list/discard_edit_button")
+ :draw{
+ type = "sprite-button",
+ sprite = "utility/close_black",
+ tooltip = { "exp-gui_warp-list.tooltip-discard" },
+ style = "shortcut_bar_button_red",
+ }
+ :style(Styles.sprite22)
+ :element_data{
+ warp = Gui.from_argument(1),
+ }
+ :on_click(function(def, player, discard_edit_button)
+ --- @cast def ExpGui_WarpList.elements.discard_edit_button
+ local warp = def.data[discard_edit_button].warp
+ if warp.new then
+ Elements.container.remove_warp(warp)
+ else
+ Elements.container.set_editing(warp, player, false)
+ end
+ end) --[[@as any]]
+
+--- @class ExpGui_WarpList.elements.edit_warp_button.element_data
+--- @field warp ExpGui_WarpList.Warp
+
+--- Button which starts editing a warp
+--- @class ExpGui_WarpList.elements.edit_warp_button: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
+Elements.edit_warp_button = Gui.define("warp_list/edit_warp_button")
+ :draw{
+ type = "sprite-button",
+ sprite = "utility/rename_icon",
+ tooltip = { "exp-gui_warp-list.tooltip-edit-none" },
+ style = "shortcut_bar_button",
+ }
+ :style(Styles.sprite22)
+ :element_data{
+ warp = Gui.from_argument(1),
+ }
+ :on_click(function(def, player, edit_warp_button)
+ --- @cast def ExpGui_WarpList.elements.edit_warp_button
+ Elements.container.set_editing(def.data[edit_warp_button].warp, player, true)
+ end) --[[@as any]]
+
+--- Refresh the tooltip which lists who is editing the warp
+--- @param edit_warp_button LuaGuiElement
+--- @param warp ExpGui_WarpList.Warp
+function Elements.edit_warp_button.refresh(edit_warp_button, warp)
+ if next(warp.editing) then
+ local player_names = table.get_values(warp.editing)
+ edit_warp_button.hovered_sprite = "utility/warning_icon"
+ edit_warp_button.tooltip = { "exp-gui_warp-list.tooltip-edit", table.concat(player_names, ", ") }
+ else
+ edit_warp_button.hovered_sprite = "utility/rename_icon"
+ edit_warp_button.tooltip = { "exp-gui_warp-list.tooltip-edit-none" }
+ end
+end
+
+--- @class ExpGui_WarpList.elements.remove_warp_button.element_data
+--- @field warp ExpGui_WarpList.Warp
+
+--- Button which removes a warp along with its area and map tag
+--- @class ExpGui_WarpList.elements.remove_warp_button: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
+Elements.remove_warp_button = Gui.define("warp_list/remove_warp_button")
+ :draw{
+ type = "sprite-button",
+ sprite = "utility/trash",
+ tooltip = { "exp-gui_warp-list.tooltip-remove" },
+ style = "shortcut_bar_button_red",
+ }
+ :style(Styles.sprite22)
+ :element_data{
+ warp = Gui.from_argument(1),
+ }
+ :on_click(function(def, _, remove_warp_button)
+ --- @cast def ExpGui_WarpList.elements.remove_warp_button
+ Elements.container.remove_warp(def.data[remove_warp_button].warp)
+ end) --[[@as any]]
+
+--- @class ExpGui_WarpList.elements.warp_table.row
+--- @field warp ExpGui_WarpList.Warp
+--- @field icon_flow LuaGuiElement
+--- @field name_flow LuaGuiElement
+--- @field action_flow LuaGuiElement
+--- @field warp_button LuaGuiElement
+--- @field icon_picker LuaGuiElement
+--- @field status_label LuaGuiElement
+--- @field name_label LuaGuiElement
+--- @field name_field LuaGuiElement
+--- @field confirm_edit_button LuaGuiElement
+--- @field discard_edit_button LuaGuiElement
+--- @field edit_warp_button LuaGuiElement
+--- @field remove_warp_button LuaGuiElement
+
+--- @class ExpGui_WarpList.elements.warp_table.element_data
+--- @field rows table Keyed by warp id
+
+--- Table with a row of icon, name, and actions for each warp of the player's force
+--- @class ExpGui_WarpList.elements.warp_table: ExpElement
+--- @field data table
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.warp_table = Gui.define("warp_list/warp_table")
+ :track_all_elements()
+ :element_data{ rows = {} }
+ :draw(function(_, parent)
+ local warp_table = Gui.elements.scroll_table(parent, 250, 3, "scroll")
+ -- Always showing the scrollbar stops the gui changing width as warps are added
+ assert(warp_table.parent).vertical_scroll_policy = "always"
+ return warp_table
+ end)
+ :style{
+ top_cell_padding = 3,
+ bottom_cell_padding = 3,
+ } --[[@as any]]
+
+--- Add a row for a warp
+--- @param warp_table LuaGuiElement
+--- @param warp ExpGui_WarpList.Warp
+--- @return ExpGui_WarpList.elements.warp_table.row
+function Elements.warp_table.add_row(warp_table, warp)
+ local icon_flow = warp_table.add{ type = "flow" }
+ icon_flow.style.padding = 0
+ local warp_button = Elements.warp_button(icon_flow, warp)
+ local icon_picker = Elements.icon_picker(icon_flow, warp)
+
+ local name_flow = warp_table.add{ type = "flow" }
+ name_flow.style.padding = 0
+ local status_label = Elements.status_label(name_flow)
+ local name_label = Elements.name_label(name_flow, warp)
+ local name_field = Elements.name_field(name_flow, warp, icon_picker)
+
+ local action_flow = warp_table.add{ type = "flow" }
+ action_flow.style.padding = 0
+ local confirm_edit_button = Elements.confirm_edit_button(action_flow, warp, name_field, icon_picker)
+ local discard_edit_button = Elements.discard_edit_button(action_flow, warp)
+ local edit_warp_button = Elements.edit_warp_button(action_flow, warp)
+ local remove_warp_button = Elements.remove_warp_button(action_flow, warp)
+
+ local row = {
+ warp = warp,
+ icon_flow = icon_flow,
+ name_flow = name_flow,
+ action_flow = action_flow,
+ warp_button = warp_button,
+ icon_picker = icon_picker,
+ status_label = status_label,
+ name_label = name_label,
+ name_field = name_field,
+ confirm_edit_button = confirm_edit_button,
+ discard_edit_button = discard_edit_button,
+ edit_warp_button = edit_warp_button,
+ remove_warp_button = remove_warp_button,
+ }
+
+ Elements.warp_table.data[warp_table].rows[warp.id] = row
+ return row
+end
+
+--- Remove the row for a warp
+--- @param warp_table LuaGuiElement
+--- @param warp ExpGui_WarpList.Warp
+function Elements.warp_table.remove_row(warp_table, warp)
+ local rows = Elements.warp_table.data[warp_table].rows
+ local row = rows[warp.id]
+ if not row then return end
+ rows[warp.id] = nil
+ row.icon_flow.destroy()
+ row.name_flow.destroy()
+ row.action_flow.destroy()
+end
+
+--- Refresh whether a warp can be used from where the player is
+--- @param row ExpGui_WarpList.elements.warp_table.row
+--- @param status ExpGui_WarpList.PlayerStatus
+function Elements.warp_table._refresh_row_status(row, status)
+ local warp = row.warp
+ local in_range = status.in_range
+ local position = warp.position
+ local tooltip, icon, enabled
+
+ if not in_range then
+ if status.bypass_proximity then
+ tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-bypass", position.x, position.y }, status_icons.bypass, true
+ else
+ tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-disabled" }, status_icons.not_available, false
+ end
+ elseif in_range == warp then
+ tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-same-warp" }, status_icons.current, false
+ elseif status.on_cooldown then
+ tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-cooldown" }, status_icons.cooldown, false
+ else
+ -- Warps are connected when their poles share an electric network, missing poles never match
+ local network_id = warp.electric_pole and warp.electric_pole.valid and warp.electric_pole.electric_network_id or -1
+ local in_range_network_id = in_range.electric_pole and in_range.electric_pole.valid and in_range.electric_pole.electric_network_id or -2
+ if network_id == in_range_network_id then
+ tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto", position.x, position.y }, status_icons.connected, true
+ elseif status.bypass_proximity then
+ tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-bypass-different-network", position.x, position.y }, status_icons.bypass, true
+ else
+ tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-different-network" }, status_icons.different, false
+ end
+ end
+
+ row.warp_button.tooltip = tooltip
+ row.warp_button.enabled = enabled
+ row.status_label.tooltip = tooltip
+ row.status_label.caption = icon
+ row.name_label.style.font = enabled and "default-semibold" or "default"
+end
+
+--- Refresh a row to match its warp, the player's permissions, and where the player is
+--- @param warp_table LuaGuiElement
+--- @param row ExpGui_WarpList.elements.warp_table.row
+--- @param status ExpGui_WarpList.PlayerStatus
+function Elements.warp_table.refresh_row(warp_table, row, status)
+ local player = Gui.get_player(warp_table)
+ local warp = row.warp
+ local can_edit = has_permission_edit_warp(player, warp)
+ local editing = warp.editing[player.index] ~= nil
+
+ Elements.edit_warp_button.refresh(row.edit_warp_button, warp)
+ Elements.name_label.refresh(row.name_label, warp)
+
+ row.warp_button.visible = not editing
+ row.icon_picker.visible = editing
+ row.name_label.visible = not editing
+ row.name_field.visible = editing
+ row.confirm_edit_button.visible = editing
+ row.discard_edit_button.visible = editing
+ row.edit_warp_button.visible = can_edit and not editing
+ row.remove_warp_button.visible = can_edit and not editing
+
+ if editing then
+ row.name_field.focus()
+ assert(warp_table.parent).scroll_to_element(row.name_field, "top-third")
+ else
+ row.warp_button.sprite = icon_sprite(warp.icon)
+ row.icon_picker.elem_value = warp.icon
+ row.name_field.text = warp.name
+ end
+
+ Elements.warp_table._refresh_row_status(row, status)
+end
+
+--- Rebuild every row in the order of the warps
+--- @param warp_table LuaGuiElement
+--- @param warps ExpGui_WarpList.Warp[]
+--- @param status ExpGui_WarpList.PlayerStatus
+function Elements.warp_table.rebuild(warp_table, warps, status)
+ warp_table.clear()
+ Elements.warp_table.data[warp_table].rows = {}
+ for _, warp in ipairs(warps) do
+ local row = Elements.warp_table.add_row(warp_table, warp)
+ Elements.warp_table.refresh_row(warp_table, row, status)
+ end
+end
+
+--- Refresh the row of one warp for the online players of its force
+--- @param warp ExpGui_WarpList.Warp
+function Elements.warp_table.refresh_row_force(warp)
+ for player, warp_table in Elements.warp_table:online_elements(warp.force) do
+ --- @cast player -nil
+ local row = Elements.warp_table.data[warp_table].rows[warp.id]
+ if row then
+ Elements.warp_table.refresh_row(warp_table, row, Elements.container.get_status(player))
+ end
+ end
+end
+
+--- Refresh whether each warp can be used from where the player is
+--- @param player LuaPlayer
+function Elements.warp_table.refresh_status_player(player)
+ local status = Elements.container.get_status(player)
+ for _, warp_table in Elements.warp_table:tracked_elements(player) do
+ for _, row in pairs(Elements.warp_table.data[warp_table].rows) do
+ Elements.warp_table._refresh_row_status(row, status)
+ end
+ end
+end
+
+--- Rebuild the rows for a player, used when their force or permissions change
+--- @param player LuaPlayer
+function Elements.warp_table.rebuild_player(player)
+ local warps = Elements.container.get_warps(player.force --[[@as LuaForce]])
+ local status = Elements.container.get_status(player)
+ for _, warp_table in Elements.warp_table:tracked_elements(player) do
+ Elements.warp_table.rebuild(warp_table, warps, status)
+ end
+end
+
+--- Rebuild the rows for the online players of a force, used when the warps change
+--- @param force LuaForce
+function Elements.warp_table.rebuild_force(force)
+ local warps = Elements.container.get_warps(force)
+ for player, warp_table in Elements.warp_table:online_elements(force) do
+ --- @cast player -nil
+ Elements.warp_table.rebuild(warp_table, warps, Elements.container.get_status(player))
+ end
+end
+
+--- Progress bar which fills as the warp cooldown runs out
+--- @class ExpGui_WarpList.elements.cooldown_bar: ExpElement
+--- @overload fun(parent: LuaGuiElement): LuaGuiElement
+Elements.cooldown_bar = Gui.define("warp_list/cooldown_bar")
+ :track_all_elements()
+ :draw{
+ type = "progressbar",
+ value = 1,
+ tooltip = { "exp-gui_warp-list.tooltip-cooldown-none", config.cooldown_duration },
+ }
+ :style{
+ horizontally_stretchable = true,
+ color = Colors.light_blue,
+ } --[[@as any]]
+
+--- Refresh the bar to show the ticks left on the cooldown
+--- @param cooldown_bar LuaGuiElement
+--- @param cooldown number
+function Elements.cooldown_bar.refresh(cooldown_bar, cooldown)
+ if cooldown > 0 then
+ cooldown_bar.value = 1 - cooldown / cooldown_ticks
+ cooldown_bar.tooltip = { "exp-gui_warp-list.tooltip-cooldown", ceil(cooldown / 60) }
+ else
+ cooldown_bar.value = 1
+ cooldown_bar.tooltip = { "exp-gui_warp-list.tooltip-cooldown-none", config.cooldown_duration }
+ end
+end
+
+--- Refresh the bars of a player
+--- @param player LuaPlayer
+--- @param cooldown number
+function Elements.cooldown_bar.refresh_player(player, cooldown)
+ for _, cooldown_bar in Elements.cooldown_bar:tracked_elements(player) do
+ Elements.cooldown_bar.refresh(cooldown_bar, cooldown)
+ end
+end
+
+--- @class ExpGui_WarpList.elements.container.elements
+--- @field frame LuaGuiElement
+--- @field add_warp_button LuaGuiElement
+--- @field warp_table LuaGuiElement
+--- @field cooldown_bar LuaGuiElement
+
+--- @class ExpGui_WarpList.elements.container.force_data
+--- @field warps ExpGui_WarpList.Warp[] The spawn warp first, then by name
+--- @field spawn ExpGui_WarpList.Warp?
+--- @field next_warp_id number
+
+--- @class ExpGui_WarpList.elements.container.player_data
+--- @field cooldown number Ticks until the player can warp again
+--- @field in_range ExpGui_WarpList.Warp? The warp the player is standing on
+--- @field blocking ExpGui_WarpList.Warp? The warp too close for another to be added
+--- @field keep_open boolean True once the player toggles the list themselves, which stops it hiding when they leave a warp
+
+--- @class ExpGui_WarpList.elements.container.data
+--- @field [LuaGuiElement] ExpGui_WarpList.elements.container.elements
+--- @field [LuaForce] ExpGui_WarpList.elements.container.force_data
+--- @field [LuaPlayer] ExpGui_WarpList.elements.container.player_data
+
+--- Container added to the left gui flow
+--- @class ExpGui_WarpList.elements.container: ExpElement
+--- @field data ExpGui_WarpList.elements.container.data
+Elements.container = Gui.define("warp_list/container")
+ :draw(function(def, parent)
+ --- @cast def ExpGui_WarpList.elements.container
+ local player = Gui.get_player(parent)
+ local allow_add_warp = has_config_permission(player, "allow_add_warp")
+ local container = Gui.elements.container(parent, allow_add_warp and 268 or 220)
+
+ local header = Gui.elements.header(container, {
+ caption = { "exp-gui_warp-list.caption-main" },
+ tooltip = {
+ "exp-gui_warp-list.tooltip-sub",
+ config.cooldown_duration,
+ config.standard_proximity_radius,
+ { "exp-gui_warp-list.tooltip-sub-current", status_icons.current },
+ { "exp-gui_warp-list.tooltip-sub-connected", status_icons.connected },
+ { "exp-gui_warp-list.tooltip-sub-different", status_icons.different },
+ { "exp-gui_warp-list.tooltip-sub-cooldown", status_icons.cooldown },
+ { "exp-gui_warp-list.tooltip-sub-not-available", status_icons.not_available },
+ { "exp-gui_warp-list.tooltip-sub-bypass", status_icons.bypass },
+ },
+ })
+
+ local add_warp_button = Elements.add_warp_button(header)
+ add_warp_button.visible = allow_add_warp
+
+ local warp_table = Elements.warp_table(container)
+ local force = player.force --[[@as LuaForce]]
+ Elements.warp_table.rebuild(warp_table, def.get_warps(force), def.get_status(player))
+
+ local cooldown_bar = Elements.cooldown_bar(container)
+ Elements.cooldown_bar.refresh(cooldown_bar, def._get_player_data(player).cooldown)
+
+ local root = Gui.elements.container.get_root_element(container)
+ def.data[root] = {
+ frame = container,
+ add_warp_button = add_warp_button,
+ warp_table = warp_table,
+ cooldown_bar = cooldown_bar,
+ }
+
+ return root
+ end) --[[@as any]]
+
+--- Get or create the data for a force
+--- @param force LuaForce
+--- @return ExpGui_WarpList.elements.container.force_data
+function Elements.container._get_force_data(force)
+ local force_data = Elements.container.data[force]
+ if not force_data then
+ force_data = { warps = {}, next_warp_id = 1 }
+ Elements.container.data[force] = force_data
+ end
+ return force_data
+end
+
+--- Get or create the data for a player
+--- @param player LuaPlayer
+--- @return ExpGui_WarpList.elements.container.player_data
+function Elements.container._get_player_data(player)
+ local player_data = Elements.container.data[player]
+ if not player_data then
+ player_data = { cooldown = 0, keep_open = false }
+ Elements.container.data[player] = player_data
+ end
+ return player_data
+end
+
+--- Get the warps of a force, the spawn warp first and then by name
+--- @param force LuaForce
+--- @return ExpGui_WarpList.Warp[]
+function Elements.container.get_warps(force)
+ return Elements.container._get_force_data(force).warps
+end
+
+--- Get the spawn warp of a force
+--- @param force LuaForce
+--- @return ExpGui_WarpList.Warp?
+function Elements.container.get_spawn_warp(force)
+ return Elements.container._get_force_data(force).spawn
+end
+
+--- Get what decides which warps a player can use
+--- @param player LuaPlayer
+--- @return ExpGui_WarpList.PlayerStatus
+function Elements.container.get_status(player)
+ local player_data = Elements.container._get_player_data(player)
+ return {
+ in_range = player_data.in_range,
+ on_cooldown = player_data.cooldown > 0,
+ bypass_proximity = has_config_permission(player, "bypass_warp_proximity"),
+ }
+end
+
+--- Keep the warps of a force in display order
+--- @param force_data ExpGui_WarpList.elements.container.force_data
+function Elements.container._sort_warps(force_data)
+ local spawn = force_data.spawn
+ table.sort(force_data.warps, function(a, b)
+ if a == spawn then return true end
+ if b == spawn then return false end
+ if a.name == b.name then return a.id < b.id end
+ return a.name < b.name
+ end)
+end
+
+--- Add or update the map tag of a warp
+--- @param warp ExpGui_WarpList.Warp
+function Elements.container._make_tag(warp)
+ local tag = warp.tag
+ if tag and tag.valid then
+ tag.text = "Warp: " .. warp.name
+ tag.icon = warp.icon
+ return
+ end
+
+ local position = warp.position
+ warp.tag = warp.force.add_chart_tag(warp.surface, {
+ position = { x = position.x + 0.5, y = position.y + 0.5 },
+ text = "Warp: " .. warp.name,
+ icon = warp.icon,
+ })
+end
+
+--- Remove the map tag of a warp
+--- @param warp ExpGui_WarpList.Warp
+function Elements.container._remove_tag(warp)
+ local tag = warp.tag
+ if tag and tag.valid then
+ tag.destroy()
+ end
+ warp.tag = nil
+end
+
+--- Place the tiles and entities which mark a warp on the ground
+--- @param warp ExpGui_WarpList.Warp
+function Elements.container._make_area(warp)
+ local surface = warp.surface
+ local position = warp.position
+ warp.old_tile = surface.get_tile(position.x, position.y).name
+
+ local tiles = {}
+ for index, tile in ipairs(config.tiles) do
+ tiles[index] = { name = tile[1], position = { x = tile[2] + position.x, y = tile[3] + position.y } }
+ end
+ surface.set_tiles(tiles)
+
+ for _, entity_config in ipairs(config.entities) do
+ local entity = assert(surface.create_entity{
+ name = entity_config[1],
+ position = { x = entity_config[2] + position.x, y = entity_config[3] + position.y },
+ force = "neutral",
+ })
+ entity.destructible = false
+ entity.health = 0
+ entity.minable_flag = false
+ entity.rotatable = false
+
+ if entity.type == "electric-pole" then
+ warp.electric_pole = entity
+ end
+ end
+end
+
+--- Restore the ground under a warp and remove its entities
+--- @param warp ExpGui_WarpList.Warp
+function Elements.container._remove_area(warp)
+ local old_tile = warp.old_tile
+ if not old_tile then return end
+
+ local surface = warp.surface
+ local position = warp.position
+ local tiles = {}
+ for index, tile in ipairs(config.tiles) do
+ tiles[index] = { name = old_tile, position = { x = tile[2] + position.x, y = tile[3] + position.y } }
+ end
+ surface.set_tiles(tiles)
+
+ local radius = config.standard_proximity_radius
+ local area = {
+ left_top = { x = position.x - radius, y = position.y - radius },
+ right_bottom = { x = position.x + radius, y = position.y + radius },
+ }
+ local entities = surface.find_entities_filtered{ force = "neutral", area = area, name = area_entity_names }
+ for _, entity in pairs(entities) do
+ if entity.destructible == false then
+ entity.destructible = true
+ entity.die(entity.force)
+ end
+ end
+
+ -- The area may not be covered by a radar
+ warp.force.chart(surface, area)
+end
+
+--- Add a warp for a force, when added by a player it is new until they confirm their first edit
+--- @param force LuaForce
+--- @param surface LuaSurface
+--- @param position MapPosition.struct
+--- @param player LuaPlayer?
+--- @param name string?
+--- @return ExpGui_WarpList.Warp
+function Elements.container.add_warp(force, surface, position, player, name)
+ local force_data = Elements.container._get_force_data(force)
+ local id = force_data.next_warp_id
+ force_data.next_warp_id = id + 1
+
+ --- @type ExpGui_WarpList.Warp
+ local warp = {
+ id = id,
+ force = force,
+ name = name or "New warp",
+ icon = { type = config.default_icon.type, name = config.default_icon.name },
+ surface = surface,
+ position = { x = floor(position.x), y = floor(position.y) },
+ last_user = player,
+ last_edit_tick = game.tick,
+ editing = {},
+ }
+
+ -- Warps added by a player start in edit mode, discarding that edit removes them again
+ if player then
+ warp.new = true
+ Elements.container._make_area(warp)
+ end
+
+ force_data.warps[#force_data.warps + 1] = warp
+ Elements.container._sort_warps(force_data)
+ Elements.container._make_tag(warp)
+
+ Elements.warp_table.rebuild_force(force)
+ return warp
+end
+
+--- Remove a warp along with its area and map tag
+--- @param warp ExpGui_WarpList.Warp
+function Elements.container.remove_warp(warp)
+ local force_data = Elements.container._get_force_data(warp.force)
+ table.remove_element(force_data.warps, warp)
+ if force_data.spawn == warp then
+ force_data.spawn = nil
+ end
+
+ Elements.container._remove_tag(warp)
+ Elements.container._remove_area(warp)
+
+ -- Offline players are corrected by their next proximity update
+ for _, player in pairs(warp.force.connected_players) do
+ local player_data = Elements.container._get_player_data(player)
+ if player_data.in_range == warp then player_data.in_range = nil end
+ if player_data.blocking == warp then player_data.blocking = nil end
+ end
+
+ for _, warp_table in Elements.warp_table:online_elements(warp.force) do
+ Elements.warp_table.remove_row(warp_table, warp)
+ end
+end
+
+--- Save a new name and icon for a warp, which also ends the player's edit
+--- @param warp ExpGui_WarpList.Warp
+--- @param name string
+--- @param icon SignalID
+--- @param player LuaPlayer
+function Elements.container.update_warp(warp, name, icon, player)
+ warp.name = name
+ warp.icon = icon
+ warp.last_user = player
+ warp.last_edit_tick = game.tick
+ warp.editing[player.index] = nil
+ warp.new = nil
+
+ Elements.container._sort_warps(Elements.container._get_force_data(warp.force))
+ Elements.container._make_tag(warp)
+ Elements.warp_table.rebuild_force(warp.force)
+end
+
+--- Start or stop a player editing a warp
+--- @param warp ExpGui_WarpList.Warp
+--- @param player LuaPlayer
+--- @param editing boolean
+function Elements.container.set_editing(warp, player, editing)
+ warp.editing[player.index] = editing and player.name or nil
+ Elements.warp_table.refresh_row_force(warp)
+end
+
+--- Make a warp the spawn of its force, the force respawns there
+--- @param warp ExpGui_WarpList.Warp
+function Elements.container.set_spawn_warp(warp)
+ local force_data = Elements.container._get_force_data(warp.force)
+ force_data.spawn = warp
+ Elements.container._sort_warps(force_data)
+ warp.force.set_spawn_position(warp.position, warp.surface)
+end
+
+--- Move a player to a warp and start their cooldown
+--- @param player LuaPlayer
+--- @param warp ExpGui_WarpList.Warp
+function Elements.container.teleport_player(player, warp)
+ local surface = warp.surface
+ local position = { x = warp.position.x + 0.5, y = warp.position.y + 0.5 }
+
+ local vehicle = player.vehicle
+ if vehicle then
+ local vehicle_position = surface.find_non_colliding_position(vehicle.name, position, 32, 1)
+ -- Only cars can be teleported between surfaces
+ if vehicle.type == "car" then
+ vehicle.teleport(vehicle_position, surface)
+ elseif surface == vehicle.surface then
+ if not vehicle.teleport(vehicle_position) then
+ player.driving = false
+ player.teleport(surface.find_non_colliding_position("character", position, 32, 1), surface)
+ end
+ end
+ else
+ player.teleport(surface.find_non_colliding_position("character", position, 32, 1), surface)
+ end
+
+ local player_data = Elements.container._get_player_data(player)
+ if not has_config_permission(player, "bypass_warp_cooldown") then
+ player_data.cooldown = cooldown_ticks
+ Elements.cooldown_bar.refresh_player(player, cooldown_ticks)
+ end
+ player_data.in_range = warp
+ Elements.warp_table.refresh_status_player(player)
+end
+
+--- Refresh the parts of a player's gui which depend on their permissions
+--- @param player LuaPlayer
+function Elements.container.refresh_player(player)
+ local allow_add_warp = has_config_permission(player, "allow_add_warp")
+ for _, container in Elements.container:tracked_elements(player) do
+ local elements = Elements.container.data[container]
+ elements.frame.style.minimal_width = allow_add_warp and 268 or 220
+ elements.add_warp_button.visible = allow_add_warp
+ end
+ Elements.warp_table.rebuild_player(player)
+end
+
+--- Count down the cooldown of a player
+--- @param player LuaPlayer
+--- @param player_data ExpGui_WarpList.elements.container.player_data
+function Elements.container._update_cooldown(player, player_data)
+ if player_data.cooldown == 0 then return end
+ local cooldown = player_data.cooldown - update_interval
+ if cooldown < 0 then cooldown = 0 end
+ player_data.cooldown = cooldown
+
+ Elements.cooldown_bar.refresh_player(player, cooldown)
+ if cooldown == 0 then
+ Elements.warp_table.refresh_status_player(player)
+ end
+end
+
+--- Find which warp a player is standing on and whether one can be added where they are
+--- @param player LuaPlayer
+--- @param player_data ExpGui_WarpList.elements.container.player_data
+function Elements.container._update_proximity(player, player_data)
+ local force_data = Elements.container._get_force_data(player.force --[[@as LuaForce]])
+ local surface = player.physical_surface
+ local position = player.physical_position
+ local px, py = position.x, position.y
+
+ local closest, closest_distance = nil, nil --- @type ExpGui_WarpList.Warp?, number?
+ for _, warp in ipairs(force_data.warps) do
+ if warp.surface == surface then
+ local dx, dy = px - warp.position.x, py - warp.position.y
+ local distance = dx * dx + dy * dy
+ if not closest_distance or distance < closest_distance then
+ closest, closest_distance = warp, distance
+ end
+ end
+ end
+
+ local in_range = nil --- @type ExpGui_WarpList.Warp?
+ if closest and closest_distance < (closest == force_data.spawn and spawn_radius_sq or proximity_radius_sq) then
+ in_range = closest
+ end
+ if in_range ~= player_data.in_range then
+ player_data.in_range = in_range
+ if not player_data.keep_open then
+ Gui.toolbar.set_left_element_visible_state(Elements.container, player, in_range ~= nil)
+ end
+ Elements.warp_table.refresh_status_player(player)
+ end
+
+ local blocking = nil --- @type ExpGui_WarpList.Warp?
+ if closest and closest_distance <= minimum_distance_sq then
+ blocking = closest
+ end
+ if blocking ~= player_data.blocking then
+ player_data.blocking = blocking
+ Elements.add_warp_button.refresh_player_blocking(player, blocking)
+ end
+end
+
+--- Add the element to the left flow with a toolbar button
+Gui.add_left_element(Elements.container, false)
+Gui.toolbar.create_button{
+ name = "toggle_warp_list",
+ left_element = Elements.container,
+ sprite = config.default_icon.type .. "/" .. config.default_icon.name,
+ tooltip = { "exp-gui_warp-list.tooltip-main" },
+ visible = function(player, element)
+ return Roles.player_has_permission(player, "exp_scenario.gui.warp_list")
+ end
+}:on_click(function(def, player)
+ -- A list the player opened themselves stays open when they leave a warp
+ Elements.container._get_player_data(player).keep_open = Gui.toolbar.get_button_toggled_state(def, player)
+end)
+
+--- Give a force a spawn warp when its first player is created
+--- @param event EventData.on_player_created
+local function on_player_created(event)
+ local player = Gui.get_player(event)
+ local force = player.force --[[@as LuaForce]]
+ if Elements.container.get_spawn_warp(force) then return end
+
+ local surface = player.physical_surface
+ local position = force.get_spawn_position(surface)
+ local warp = Elements.container.add_warp(force, surface, position, nil, "Spawn")
+ Elements.container.set_spawn_warp(warp)
+
+ -- Spawn has no warp area, so any pole nearby connects it to the network
+ local poles = surface.find_entities_filtered{ type = "electric-pole", position = position, radius = 20, limit = 1 }
+ warp.electric_pole = poles[1]
+end
+
+--- Rebuild the gui for a player as it may be outdated
+--- @param event EventData.on_player_joined_game | EventData.on_player_changed_force
+local function refresh_player_warps(event)
+ local player = Gui.get_player(event)
+ Elements.container.refresh_player(player)
+ Elements.cooldown_bar.refresh_player(player, Elements.container._get_player_data(player).cooldown)
+end
+
+--- Rebuild the gui for a player as their permissions may have changed
+--- @param event EventData.ExpRoles.on_player_roles_changed
+local function refresh_player_permissions(event)
+ Elements.container.refresh_player(Gui.get_player(event))
+end
+
+--- Restore the map tag of a warp when a player edits or removes it
+--- @param event EventData.on_chart_tag_modified | EventData.on_chart_tag_removed
+local function on_chart_tag_changed(event)
+ if not event.player_index then return end
+ for _, warp in ipairs(Elements.container.get_warps(event.force)) do
+ local tag = warp.tag
+ if not tag or not tag.valid or tag == event.tag then
+ if event.name == defines.events.on_chart_tag_removed then
+ warp.tag = nil
+ end
+ Elements.container._make_tag(warp)
+ end
+ end
+end
+
+--- Count down cooldowns and check where each player is
+local function update_players()
+ for _, player in pairs(game.connected_players) do
+ local player_data = Elements.container._get_player_data(player)
+ Elements.container._update_cooldown(player, player_data)
+ Elements.container._update_proximity(player, player_data)
+ end
+end
+
+local e = defines.events
+
+return {
+ elements = Elements,
+ events = {
+ [e.on_player_created] = on_player_created,
+ [e.on_player_joined_game] = refresh_player_warps,
+ [e.on_player_changed_force] = refresh_player_warps,
+ [e.on_chart_tag_modified] = on_chart_tag_changed,
+ [e.on_chart_tag_removed] = on_chart_tag_changed,
+ [Roles.events.on_player_roles_changed] = refresh_player_permissions,
+ },
+ on_nth_tick = {
+ [update_interval] = update_players,
+ }
+}
diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg
index cd7711f5..a0ee24c6 100644
--- a/exp_scenario/module/locale/en.cfg
+++ b/exp_scenario/module/locale/en.cfg
@@ -31,7 +31,7 @@ description-pollution-enabled=Set polution enabled state for this game.
description-game-speed=Set or get the current game speed.
arg-state=State to set, default is to toggle the current value.
arg-player=Player to toggle cheat mode for.
-arg-force-friendly-fire=Force to research all technology for.
+arg-force-friendly-fire=Force to toggle friendly fire.
arg-force-research=Force to research all technology for.
arg-surface=Surface to toggle always day for.
arg-amount=Amount to set, returns the current value if not given.
@@ -150,16 +150,16 @@ arg-items-per-second=Target items per second, defaults to output of a single mac
not-selecting=Please select an entity with a recipe.
item-in=You need __1__ per second of [item=__2__].
fluid-in=You need __1__ per second of [fluid=__2__].
-item-out=This will result in: __1__ [item=__2__] per second.
-fluid-out=This will result in: __1__ [fluid=__2__] per second.
+item-out=This will result in: __1__ [item=__2__] per second.
+fluid-out=This will result in: __1__ [fluid=__2__] per second.
machine-count=And you will need __1__ machines (with the same speed as this one) for this.
[exp-commands_repair]
selection-name=Repair
description=Repairs entities on your force around you.
# Intentional space left on the two lines below so they can be joined together.
-response-revive=__1__ entities were revived.
-response-heal=__1__ entities were healed.
+response-revive=__1__ entities were revived.
+response-heal=__1__ entities were healed.
[exp-commands_reports]
description-create=Reports a player and notifies moderators
@@ -480,6 +480,70 @@ caption-create-footer=Create task
caption-edit-footer=Edit task
caption-view-footer=Task details
+[exp-gui_vlayer]
+tooltip-main=Virtual Layer
+selection-name=vlayer
+caption-stat-solar=[img=entity/solar-panel] Solar Panels
+tooltip-stat-solar=Solar panels in use out of those stored
+caption-stat-accumulator=[img=entity/accumulator] Accumulators
+tooltip-stat-accumulator=Accumulators in use out of those stored
+caption-stat-surface-area=[virtual-signal=signal-R] Remaining Surface
+tooltip-stat-surface-area=Remaining surface area, insert landfill to increase it
+caption-stat-sustained=[virtual-signal=signal-S] Sustained Production
+tooltip-stat-sustained=Power production averaged over a day, out of what every stored solar panel could give
+caption-stat-production=[virtual-signal=signal-P] Current Production
+tooltip-stat-production=Power production right now, out of the production in full daylight
+caption-stat-capacity=[virtual-signal=signal-E] Battery Charge
+tooltip-stat-capacity=Stored energy out of the accumulator capacity
+type-energy=Energy
+type-circuit=Circuit
+type-storage-input=Storage Input
+type-storage-output=Storage Output
+caption-refresh=Refresh List
+caption-view=View Interface
+caption-build=Build Interface
+caption-remove=Remove Interface
+message-located=The __1__ interface is at __2__
+message-built=__1__ built a __2__ interface at __3__
+message-removed=__1__ removed a __2__ interface at __3__
+message-no-chest=No steel chest was selected
+message-chest-not-empty=The steel chest is not empty
+message-multiple=Cannot build a __1__ interface, more than one steel chest was selected
+message-limit=Cannot build a __1__ interface, the limit has been reached
+message-no-space=Cannot build an energy interface here, there is not enough space
+message-research=You need to research [technology=__1__] to level __2__ to use the vlayer on a space platform
+
+[exp-gui_warp-list]
+caption-main=Warp List [img=info]
+tooltip-main=Warp List
+tooltip-sub=Warps can only be used every __1__ seconds and when within __2__ tiles\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__
+tooltip-sub-current= - __1__ This is your current warp point;
+tooltip-sub-connected= - __1__ You can travel to this warp point;
+tooltip-sub-different= - __1__ This warp is on a different network;
+tooltip-sub-cooldown= - __1__ You are currently on cooldown;
+tooltip-sub-not-available= - __1__ You are not in range of a warp point;
+tooltip-sub-bypass= - __1__ Your role allows you to travel here;
+tooltip-add=Add new warp
+tooltip-add-too-close=Cannot create warp; too close to existing warp point: __1__
+add-warning-water=Cannot create warp; too close to water, please move __1__ tiles away from the water body or landfill it
+add-warning-entities=Cannot create warp; too close to other entities, please move __1__ tiles away from the entities or remove them
+tooltip-icon=Edit warp icon
+tooltip-last-edit=Last edited by __1__ at __2__\nClick to view on map
+tooltip-confirm=Save changes
+tooltip-discard=Discard changes
+tooltip-edit=Currently being edited by: __1__
+tooltip-edit-none=Currently being edited by: Nobody
+tooltip-remove=Remove warp
+tooltip-cooldown=Please wait __1__ seconds before you can warp
+tooltip-cooldown-none=After each warp you will need to wait __1__ seconds before you can warp again
+tooltip-goto=Go to x __1__ y __2__
+tooltip-goto-bypass=Go to x __1__ y __2__, bypass mode
+tooltip-goto-bypass-different-network=Go to x __1__ y __2__, bypass mode different network
+tooltip-goto-same-warp=You are already on this warp
+tooltip-goto-different-network=This warp is on a different network, use power poles to connect it
+tooltip-goto-cooldown=You are on cooldown, wait for your cooldown to recharge
+tooltip-goto-disabled=You are not on a warp point, walk to a warp point
+
[exp_afk-kick]
kick-message=All players were kicked because everyone was AFK.
@@ -490,30 +554,30 @@ reply-online=There are __1__ players online
reply-players=There have been __1__ players on this map
reply-dev=Cooldude2606 is a dev for this server and makes the scenario and is not a factorio dev.
reply-softmod=A softmod is a custom scenario that runs on this server, an example is the player list.
-reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods.
+reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods.
reply-blame=Blame __1__ for what just happened!
reply-afk=You're afk? Look at __1__, that player has been afk for: __2__
reply-evolution=Current evolution factor is __1__
reply-magic=Fear the admin magic (ノ゚∀゚)ノ⌒・*:.。. .。.:*・゜゚・*☆
reply-aids=≖ ‿ ≖ Fear the aids of a public server ≖ ‿ ≖
reply-riot=(admins) ┬┴┬┴┤ᵒ_ᵒ)├┬┴┬┴ ‹ ‹\(´ω` )/››‹‹\ ( ´)/››‹‹\ ( ´ω`)/›› (rest of server)
-reply-loops=NO LOOPS; LOOPS ARE BAD; JUST NO LOOPS!!!!!; IF YOU MAKE A LOOP.... IT WILL NOT END WELL!!!!!!!
+reply-loops=NO LOOPS; LOOPS ARE BAD; JUST NO LOOPS!!!; IF YOU MAKE A LOOP… IT WILL NOT END WELL!!!
reply-lenny=( ͡° ͜ʖ ͡°)
reply-hodor=Hodor
-reply-popcorn-1=Heating the oil and waiting for the popping sound...
+reply-popcorn-1=Heating the oil and waiting for the popping sound…
reply-popcorn-2=__1__ your popcorn is finished. Lean backwards and watch the drama unfold.
-reply-snaps-1=Pouring the glasses and finding the correct song book...
-reply-snaps-2=Singing a song...🎤🎶
+reply-snaps-1=Pouring the glasses and finding the correct song book…
+reply-snaps-2=Singing a song…🎤🎶
reply-snaps-3=schkål, my friends!
-reply-cocktail-1= 🍸 Inintiating mind reading unit... 🍸
+reply-cocktail-1= 🍸 Inintiating mind reading unit… 🍸
reply-cocktail-2= 🍸 Mixing favourite ingredients of __1__ 🍸
reply-cocktail-3=🍸 __1__ your cocktail is done.🍸
-reply-coffee-1= ☕ Boiling the water and grinding the coffee beans... ☕
+reply-coffee-1= ☕ Boiling the water and grinding the coffee beans… ☕
reply-coffee-2= ☕ __1__ we ran out of coffe beans! Have some tea instead. ☕
-reply-pizza-1= 🍕 Finding nearest pizza supplier... 🍕
+reply-pizza-1= 🍕 Finding nearest pizza supplier… 🍕
reply-pizza-2= 🍕 Figuring out the favourite pizza of __1__ 🍕
reply-pizza-3= 🍕 __1__ your pizza is here! 🍕
-reply-tea-1= ☕ Boiling the water... ☕
+reply-tea-1= ☕ Boiling the water… ☕
reply-tea-2= ☕ __1__ your tea is done! ☕
reply-mead-1= Filling the drinking horn
reply-mead-2= Skål!
diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg
index bc46d985..19d7d004 100644
--- a/exp_scenario/module/locale/zh-CN.cfg
+++ b/exp_scenario/module/locale/zh-CN.cfg
@@ -1,9 +1,9 @@
[exp-commands-authorities_role]
-deny=你沒有權限運行該項目
+deny=你沒有權限運行該項目。
[exp-commands-parse_role]
-lower-role=用戶組比你現有的更高
-lower-role-player=用戶有更高權限
+lower-role=用戶組比你現有的更高。
+lower-role-player=用戶有更高權限。
[exp-commands_admin-chat]
description=一個只有管理員能看到的對話。
@@ -12,31 +12,31 @@ format=[AC] __1__: __2__
[exp-commands_artillery]
selection-name=火炮
-description=火炮遙控
-invalid-area=所選地區無效
+description=火炮遙控。
+invalid-area=所選地區無效。
[exp-commands_bot-queue]
description=取得或更改建設隊列數量。
-arg-amount=建設隊列數量
+arg-amount=建設隊列數量。
get=建設隊列為 __1__ 個成功嘗試,和 __2__ 個嘗試。
set=__1__ 把建設隊列改成 __2__ 個成功嘗試,和 __3__ 個嘗試。
[exp-commands_cheat]
description-cheat-mode=為你或其他玩家切換作弊模式。
-description-always-day=啟用永久白天
-description-friendly-fire=啟用友方傷害
-description-research-all=啟用所有科技
-description-clear-pollution=清除污染
-description-pollution-enabled=啟用污染
-description-game-speed=設定遊戲速度
-arg-state=狀態
-arg-player=用戶
-arg-force-friendly-fire=勢力
-arg-force-research=勢力
-arg-surface=位面
-arg-amount=數量
+description-always-day=啟用永久白天。
+description-friendly-fire=啟用友方傷害。
+description-research-all=啟用所有科技。
+description-clear-pollution=清除污染。
+description-pollution-enabled=啟用污染。
+description-game-speed=設定遊戲速度。
+arg-state=要設定的狀態,預設是切換目前值。
+arg-player=要為其切換作弊模式的玩家。
+arg-force-friendly-fire=為勢力切換友方傷害。
+arg-force-research=為勢力研究所有科技。
+arg-surface=為位面切換永久白晝。
+arg-amount=要設定的數量,如果未指定,則傳回目前值。
cheat-mode=作弊模式現在為 __1__
-always-day=__1__ 把在 __2__ 的位面的永久白天設置為 __3__ 。
+always-day=__1__ 把在 __2__ 的位面的永久白晝設置為 __3__ 。
friendly-fire=__1__ 已為在 __2__ 的勢力設定成 __3__ 。
research-all=__1__ 已為在 __2__ 的勢力啟用所有科技 。
clear-pollution=__1__ 已為在 __2__ 的位面清除所有污染 。
@@ -46,15 +46,15 @@ game-speed-get=現在遊戲速度為 __1__ 。
[exp-commands_clear-inventory]
description=清除用戶物品。
-arg-player=用戶
+arg-player=要清除其物品欄的用戶。
[exp-commands_connect]
-description=連接到另一服務器
-description-player=讓其他用戶連接到另一服務器
-description-all=將所有玩家連接到另一台服務器
-arg-player=連接到別的服的用戶
-arg-server=輸入為地址 / 名稱
-arg-is-address=輸入為地址
+description=連接到另一服務器。
+description-player=讓其他用戶連接到另一服務器。
+description-all=將所有玩家連接到另一台服務器。
+arg-player=連接到別的服務器的用戶。
+arg-server=輸入為地址或名稱。
+arg-is-address=輸入應該為地址。
too-many-matching=該服務器名稱有多過一個結果: __1__
wrong-version=該服務器版本不同: __1__
same-server=你已連接該服務器: __1__
@@ -65,15 +65,15 @@ none-matching=沒找到服務器。
description=打開偵錯介面。
[exp-commands_enemy]
-description-kill=僅移除蟲子
-description-remove=移除蟲子及停止產生
+description-kill=僅移除蟲單位。
+description-remove=移除蟲單位及停止生產器。
arg-surface=位面
[exp-commands_home]
-description-home=傳送回家
-description-set=設定目前位置為家
-description-get=取得家的位置
-description-return=傳送回上一個位置
+description-home=傳送回家。
+description-set=設定目前位置為家。
+description-get=取得家的位置。
+description-return=傳送回上一個位置。
no-home=你在 __1__ 上還沒設回程傳送點。
no-return=你還沒在 __1__ 上使用過回程。
home-set=你的回程傳送點為 __1__ 上, x: __2__ y: __3__
@@ -84,7 +84,7 @@ home-get=你的回程傳送點在 __1__ 上, x: __2__ y: __3__
description=把用戶送監而禁止其行動。
arg-player=用戶
arg-reason=原因
-jailed=__1__ 已被 __2__ ,因 __3__ 而被禁止行動。
+jailed=__1__ 已被 __2__ ,因 __3__ 而被禁止行動
already-jailed=__1__ 已被禁止行動。
[exp-commands_unjail]
@@ -101,18 +101,18 @@ lower-role=用戶組比你現有的更高
[exp-commands_lawnmower]
selection-name=割草機
-description=清理屍體、裝飾和核洞
+description=清理屍體、裝飾和核彈洞。
arg-range=半徑
complete=__1__ 格已完成。
[exp-commands_locate]
-description=在地圖上查找用戶
+description=在地圖上查找用戶的最後位置。
arg-player=用戶
-arg-remote=若在使用地圖查看模式
+arg-remote=若為 true,則開啟在玩家遠端視圖的位置。
response=__1__ 的最後地點是 [gps=__2__,__3__]
[exp-commands_me]
-description=在聊天中發送操作訊息
+description=在聊天中發送操作訊息。
arg-action=你想做的動作。
response=* __1__ __2__ *
@@ -121,17 +121,19 @@ description-entity=啟用建築保護
description-area=啟用區域保護
enter-entity=現在進入建築保護,選擇一個建築來保護,按住 shift 來選擇則可取消。
enter-area=現在進入區域保護,選擇一個區域來保護,按住 shift 來選擇則可取消。
-exit-entity=已進入建築保護
-exit-area=已進入區域選擇
+exit-entity=已進入建築保護。
+exit-area=已進入區域選擇。
protected-entities=__1__ 個建築現已受保護。
unprotected-entities=__1__ 個建築現不受保護。
already-protected=本地區已受保護。
protected-area=本地區現已受保護。
unprotected-area=本地區現不受保護。
repeat-offence=__1__ 在 [gps=__3__,__4__] 拆除了 __2__
+enter-entity-selection = Entered entity selection, select entites to protect, hold shift to remove protection.
+enter-area-selection = Entered area selection, select areas to protect, hold shift to remove protection.
[exp-commands_tag-protection]
-description=切換管理員標記模式,新標記只能由管理員編輯
+description=切換管理員標記模式,新標記只能由管理員編輯。
exit=你已離開管理員地圖標記模式,所有新地圖標記將不受保護。
enter=你已進入管理員地圖標記模式,所有新地圖標記將受保護。
create=你放了一個管理員地圖標記。
@@ -153,6 +155,7 @@ fluid-in=你每秒需要 __1__ 來做 [fluid=__2__]。
item-out=結果: __1__ [item=__2__] 每秒。
fluid-out=結果: __1__ [fluid=__2__] 每秒。
machines=你需要 __1__ 部同樣的機器。
+machine-count = And you will need __1__ machines (with the same speed as this one) for this.
[exp-commands_repair]
selection-name=維修
@@ -172,11 +175,21 @@ arg-player-clear=用戶
arg-reason=原因
arg-from-player=用戶
player-immune=用戶不能被舉報
-self-report=你不能舉報自己
+self-report=你不能舉報自己。
+response=__1__ 因 __2__ 被舉報了。
+response-admin=__1__ 因 __3__ 被 __2__ 舉報了。
+already-reported=你已經舉報過該用戶了,你可以讓管理員清除報告來重寫。
+not-reported=該用戶沒有被舉報的紀錄。
+not-reported-by=該用戶沒有被 __1__ 舉報的紀錄。
+reports-title==該用戶現在的被舉報紀錄:
+player-title=__1__ 有 __2__ 項被舉報的紀錄:
+list-element=__1__: __2__
+removed-all=__1__ 被舉報的所有紀錄已被 __2__ 清除。
+removed=__1__ 被舉報的一個紀錄已被 __2__ 清除。
[exp-commands_roles]
-description-assign=為用戶指配用戶組
-description-unassign=為用戶取消指配用戶組
+description-assign=為用戶指配用戶組。
+description-unassign=為用戶取消指配用戶組。
description-get=顯示所有用戶組
arg-player-assign=指配
arg-player-unassign=取消指配
@@ -199,16 +212,16 @@ title=有 [item=__1__] 的用戶:
result=\n__1__) __2__ 有 __3__ 個。 (__4__)
[exp-commands_spectate]
-description-spectate=切換旁觀者模式
-description-follow=開始在旁觀者模式中關注玩家
+description-spectate=切換旁觀者模式。
+description-follow=開始在旁觀者模式中關注玩家。
arg-player=用戶
-follow-self=你不能追蹤自己
+follow-self=你不能追蹤自己。
[exp-commands_surface]
selection-name=位面
-description-items=清除地面上的物品
-description-blueprints=清除藍圖
-description-blueprints-surface=清除藍圖
+description-items=清除地面上的物品。
+description-blueprints=清除藍圖。
+description-blueprints-surface=清除本位面上的藍圖。
arg-surface=位面
items=__1__ 清除了所有在 __2__ 中掉地上的東西。
blueprints=__1__ 清除了所有在 __2__ 中在地上的藍圖。
@@ -225,9 +238,9 @@ response=__1__ 把 __2__ 個火車設置為自動模式。
description-teleport=將一名玩家傳送到另一名玩家。
description-bring=將一名玩家傳送到你身邊。
description-goto=將你傳送到玩家身邊。
-description-spawn=傳送到出生點。
+description-spawn=傳送到出生點
arg-player-teleport=傳送的玩家,若沒有目標,你會被傳送到這。
-arg-player-from=傳送的玩家
+arg-player-from=傳送的玩家。
arg-player-to=傳送的玩家目標
unavailable=沒找到空間進行傳送
same-player=沒辦法傳送到自己
@@ -379,6 +392,7 @@ data-statistics=數據
data-required=需要
data-misc=雜項
data-format=__1____2__
+welcome-title = Welcome to __1__
[exp-gui_research-milestones]
tooltip-main=研究介面
@@ -410,7 +424,7 @@ data-caption-fastest-launch=最快發射
data-tooltip-fastest-launch=最快發射時間
data-caption-total-rockets=總發射次數
data-tooltip-total-rockets=我方總發射次數
-value-tooltip-total-rockets=__1__% 的火箭發射都在本地圖上。
+value-tooltip-total-rockets=__1__% 的火箭發射都在這張地圖上
data-caption-avg-launch=平均時間
data-tooltip-avg-launch=火箭發射平均時間
data-caption-avg-launch-n=平均時間 __1__
@@ -453,8 +467,8 @@ tooltip-cursor-empty=游標為空
caption-main=工作流程 [img=info]
tooltip-main=工作流程
tooltip-sub=需要完成的工作流程\n- 你可以用富文本來加上圖片 [img=utility/not_enough_repair_packs_icon] 或 [color=blue] 顏色[/color]。
-caption-no-tasks=沒有工作流程
-tooltip-no-tasks=按加號加入工作流程
+caption-no-tasks=沒有工作流程!
+tooltip-no-tasks=按加號加入工作流程!
tooltip-last-edit=最後由 __1__ 在 __2__ 修改
tooltip-close=關閉工作流程
tooltip-new=加入工作流程
@@ -470,41 +484,106 @@ tooltip-edit-none=現沒有被人修改
caption-create-footer=加入工作流程
caption-edit-footer=修改工作流程
caption-view-footer=工作流程細節
+tooltip-confirm = Save changes (minimum of 5 characters long)
+
+[exp-gui_warp-list]
+caption-main=傳送陣清單 [img=info]
+tooltip-main=傳送陣清單
+tooltip-sub=傳送陣在 __1__ 秒內只能使用一次。 亦需在 __2__ 格之內使用\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__
+tooltip-sub-current= - __1__ 你目前的傳送陣;
+tooltip-sub-connected= - __1__ 你可以到本傳送陣;
+tooltip-sub-different= - __1__ 該傳送陣未連接;
+tooltip-sub-cooldown= - __1__ 傳送冷卻中;
+tooltip-sub-not-available= - __1__ 你不在傳送陣範圍;
+tooltip-sub-bypass= - __1__ 你可以傳送到這裏;
+tooltip-add=新建傳送陣
+tooltip-add-too-close=無法新建傳送陣; 太接近 __1__ 了
+add-warning-water=無法新建傳送陣; 太接近水源了, 請移動 __1__ 格又或者填平該地方
+add-warning-entities=無法新建傳送陣; 太接近其他東西, 請移動 __1__ 格又或者拆除該東西
+tooltip-icon=修改傳送陣圖案
+tooltip-last-edit=最後由 __1__ 在 __2__ 更改\n按這在地圖上看
+tooltip-confirm=儲存更改
+tooltip-discard=放棄更改
+tooltip-edit=現被 __1__ 修改中
+tooltip-edit-none=現沒有被人修改
+tooltip-remove=拆除傳送陣
+tooltip-cooldown=請等 __1__ 秒才再次傳送
+tooltip-cooldown-none=你要等 __1__ 秒才可以再次傳送
+tooltip-goto=到 X __1__ Y __2__
+tooltip-goto-bypass=到 X __1__ Y __2__, 繞行模式
+tooltip-goto-bypass-different-network=到 X __1__ Y __2__, 繞行模式 (不同網絡)
+tooltip-goto-same-warp=你已在該傳送陣
+tooltip-goto-different-network=該傳送陣未連接, 你可使用電纜連接
+tooltip-goto-cooldown=傳送冷卻中, 請等候冷卻
+tooltip-goto-disabled=你不在傳送陣, 請移至任意傳送陣
+
+[exp-gui_vlayer]
+tooltip-main=Vlayer 介面
+selection-name=vlayer
+caption-stat-solar=[img=entity/solar-panel] 太陽能板
+tooltip-stat-solar=使用中的太陽能板數量 / 儲存的太陽能板數量
+caption-stat-accumulator=[img=entity/accumulator] 蓄電池
+tooltip-stat-accumulator=使用中的蓄電池數量 / 儲存的蓄電池數量
+caption-stat-surface-area=[virtual-signal=signal-R] 餘下土地
+tooltip-stat-surface-area=餘下土地。 放入堆填來增加
+caption-stat-sustained=[virtual-signal=signal-S] 持續產能
+tooltip-stat-sustained=持續電力產能
+caption-stat-production=[virtual-signal=signal-P] 現時產能
+tooltip-stat-production=現時電力產能
+caption-stat-capacity=[virtual-signal=signal-E] 電池電量
+tooltip-stat-capacity=現時電池電量 / 電池最大容量
+type-energy=電力
+type-circuit=回路
+type-storage-input=放入箱
+type-storage-output=提取箱
+caption-refresh=刷新列表
+caption-view=查看介面
+caption-build=建造介面
+caption-remove=拆除介面
+message-located=__1__ 介面在 __2__
+message-built=__1__ 在 __3__ 建造了 __2__ 介面
+message-removed=__1__ 在 __3__ 拆除了 __2__ 介面
+message-no-chest=沒檢測到鋼箱
+message-chest-not-empty=鋼箱不是空的
+message-multiple=無法建造 __1__ 介面,檢測到多個鋼箱
+message-limit=無法建造 __1__ 介面,超出最大限制
+message-no-space=無法在此建造電力介面,空間不足
+message-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太空平台 vlayer 功能。
[exp_afk-kick]
kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。
[exp_chat-auto-reply]
chat-reply=[Compilatron] __1__
-chat-disallowed=你沒有權限使用這個指令。
-reply-online=現在有 __1__ 人上線。
-reply-players=本地圖現在有 __1__ 人曾上線。
-reply-dev=Cooldude2606 只是本場境的開發者
+chat-disallowed=You can't use global chat commands
+reply-online=現在有 __1__ 人在線
+reply-players=這張地圖上共有__1__名用戶
+reply-dev=Cooldude2606 is a dev for this server and makes the scenario and is not a factorio dev.
reply-softmod=這裹用了自設情境。
reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods.
-reply-blame=責怪 __1__ 吧。
-reply-afk=看看 __1__, 他已掛機 __2__ 。
-reply-evolution=現在敵人進化度為 __1__ 。
+reply-blame=責怪 __1__ 吧!
+reply-afk=看看 __1__,他的掛機時長為 __2__
+reply-evolution=現在敵人進化度為 __1__
reply-magic=Fear the admin magic (ノ゚∀゚)ノ⌒・*:.。. .。.:*・゜゚・*☆
reply-aids=≖ ‿ ≖ Fear the aids of a public server ≖ ‿ ≖
reply-riot=(admins) ┬┴┬┴┤ᵒ_ᵒ)├┬┴┬┴ ‹ ‹\(´ω` )/››‹‹\ ( ´)/››‹‹\ ( ´ω`)/›› (rest of server)
-reply-loops=架設迴旋處最終後果都不好。
+reply-loops=NO LOOPS; LOOPS ARE BAD; JUST NO LOOPS!!!; IF YOU MAKE A LOOP… IT WILL NOT END WELL!!!
reply-lenny=( ͡° ͜ʖ ͡°)
reply-hodor=Hodor
-reply-popcorn-1=Heating the oil and waiting for the popping sound...
+reply-popcorn-1=Heating the oil and waiting for the popping sound…
reply-popcorn-2=__1__ your popcorn is finished. Lean backwards and watch the drama unfold.
-reply-snaps-1=Pouring the glasses and finding the correct song book...
-reply-snaps-2=Singing a song...🎤🎶
+reply-snaps-1=Pouring the glasses and finding the correct song book…
+reply-snaps-2=Singing a song…🎤🎶
reply-snaps-3=schkål, my friends!
-reply-cocktail-1= 🍸 Inintiating mind reading unit... 🍸
+reply-cocktail-1= 🍸 Inintiating mind reading unit… 🍸
reply-cocktail-2= 🍸 Mixing favourite ingredients of __1__ 🍸
reply-cocktail-3=🍸 __1__ your cocktail is done.🍸
-reply-coffee-1= ☕ Boiling the water and grinding the coffee beans... ☕
+reply-coffee-1= ☕ Boiling the water and grinding the coffee beans… ☕
reply-coffee-2= ☕ __1__ we ran out of coffe beans! Have some tea instead. ☕
-reply-pizza-1= 🍕 Finding nearest pizza supplier... 🍕
+reply-pizza-1= 🍕 Finding nearest pizza supplier… 🍕
reply-pizza-2= 🍕 Figuring out the favourite pizza of __1__ 🍕
reply-pizza-3= 🍕 __1__ your pizza is here! 🍕
-reply-tea-1= ☕ Boiling the water... ☕
+reply-tea-1= ☕ Boiling the water… ☕
reply-tea-2= ☕ __1__ your tea is done! ☕
reply-mead-1= Filling the drinking horn
reply-mead-2= Skål!
@@ -536,3 +615,22 @@ chat-jailed=__1__ 因被多次拆除受保護物體而被禁止行動。請等
[exp_report-jail]
chat-jailed=__1__ 因被多次舉報而被禁止行動。請等候管理員作出下一步處理。
+
+
+[exp-gui_player-list]
+main-tooltip = Player List
+open-action-bar = Options
+close-action-bar = Close Options
+reason-confirm = Confirm Reason
+reason-entry = Enter Reason
+goto-player = Goto player
+bring-player = Bring player
+report-player = Report player
+jail-player = Jail player
+kick-player = Kick player
+ban-player = Ban player
+afk-time = __1__% of total map time\nLast moved __2__ ago
+open-map = __1__ __2__\n__3__\nClick to open map
+
+[exp_spectate]
+follow-label = Following __1__.\nClick here or press escape to stop following.
\ No newline at end of file
diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg
index bc46d985..19d7d004 100644
--- a/exp_scenario/module/locale/zh-TW.cfg
+++ b/exp_scenario/module/locale/zh-TW.cfg
@@ -1,9 +1,9 @@
[exp-commands-authorities_role]
-deny=你沒有權限運行該項目
+deny=你沒有權限運行該項目。
[exp-commands-parse_role]
-lower-role=用戶組比你現有的更高
-lower-role-player=用戶有更高權限
+lower-role=用戶組比你現有的更高。
+lower-role-player=用戶有更高權限。
[exp-commands_admin-chat]
description=一個只有管理員能看到的對話。
@@ -12,31 +12,31 @@ format=[AC] __1__: __2__
[exp-commands_artillery]
selection-name=火炮
-description=火炮遙控
-invalid-area=所選地區無效
+description=火炮遙控。
+invalid-area=所選地區無效。
[exp-commands_bot-queue]
description=取得或更改建設隊列數量。
-arg-amount=建設隊列數量
+arg-amount=建設隊列數量。
get=建設隊列為 __1__ 個成功嘗試,和 __2__ 個嘗試。
set=__1__ 把建設隊列改成 __2__ 個成功嘗試,和 __3__ 個嘗試。
[exp-commands_cheat]
description-cheat-mode=為你或其他玩家切換作弊模式。
-description-always-day=啟用永久白天
-description-friendly-fire=啟用友方傷害
-description-research-all=啟用所有科技
-description-clear-pollution=清除污染
-description-pollution-enabled=啟用污染
-description-game-speed=設定遊戲速度
-arg-state=狀態
-arg-player=用戶
-arg-force-friendly-fire=勢力
-arg-force-research=勢力
-arg-surface=位面
-arg-amount=數量
+description-always-day=啟用永久白天。
+description-friendly-fire=啟用友方傷害。
+description-research-all=啟用所有科技。
+description-clear-pollution=清除污染。
+description-pollution-enabled=啟用污染。
+description-game-speed=設定遊戲速度。
+arg-state=要設定的狀態,預設是切換目前值。
+arg-player=要為其切換作弊模式的玩家。
+arg-force-friendly-fire=為勢力切換友方傷害。
+arg-force-research=為勢力研究所有科技。
+arg-surface=為位面切換永久白晝。
+arg-amount=要設定的數量,如果未指定,則傳回目前值。
cheat-mode=作弊模式現在為 __1__
-always-day=__1__ 把在 __2__ 的位面的永久白天設置為 __3__ 。
+always-day=__1__ 把在 __2__ 的位面的永久白晝設置為 __3__ 。
friendly-fire=__1__ 已為在 __2__ 的勢力設定成 __3__ 。
research-all=__1__ 已為在 __2__ 的勢力啟用所有科技 。
clear-pollution=__1__ 已為在 __2__ 的位面清除所有污染 。
@@ -46,15 +46,15 @@ game-speed-get=現在遊戲速度為 __1__ 。
[exp-commands_clear-inventory]
description=清除用戶物品。
-arg-player=用戶
+arg-player=要清除其物品欄的用戶。
[exp-commands_connect]
-description=連接到另一服務器
-description-player=讓其他用戶連接到另一服務器
-description-all=將所有玩家連接到另一台服務器
-arg-player=連接到別的服的用戶
-arg-server=輸入為地址 / 名稱
-arg-is-address=輸入為地址
+description=連接到另一服務器。
+description-player=讓其他用戶連接到另一服務器。
+description-all=將所有玩家連接到另一台服務器。
+arg-player=連接到別的服務器的用戶。
+arg-server=輸入為地址或名稱。
+arg-is-address=輸入應該為地址。
too-many-matching=該服務器名稱有多過一個結果: __1__
wrong-version=該服務器版本不同: __1__
same-server=你已連接該服務器: __1__
@@ -65,15 +65,15 @@ none-matching=沒找到服務器。
description=打開偵錯介面。
[exp-commands_enemy]
-description-kill=僅移除蟲子
-description-remove=移除蟲子及停止產生
+description-kill=僅移除蟲單位。
+description-remove=移除蟲單位及停止生產器。
arg-surface=位面
[exp-commands_home]
-description-home=傳送回家
-description-set=設定目前位置為家
-description-get=取得家的位置
-description-return=傳送回上一個位置
+description-home=傳送回家。
+description-set=設定目前位置為家。
+description-get=取得家的位置。
+description-return=傳送回上一個位置。
no-home=你在 __1__ 上還沒設回程傳送點。
no-return=你還沒在 __1__ 上使用過回程。
home-set=你的回程傳送點為 __1__ 上, x: __2__ y: __3__
@@ -84,7 +84,7 @@ home-get=你的回程傳送點在 __1__ 上, x: __2__ y: __3__
description=把用戶送監而禁止其行動。
arg-player=用戶
arg-reason=原因
-jailed=__1__ 已被 __2__ ,因 __3__ 而被禁止行動。
+jailed=__1__ 已被 __2__ ,因 __3__ 而被禁止行動
already-jailed=__1__ 已被禁止行動。
[exp-commands_unjail]
@@ -101,18 +101,18 @@ lower-role=用戶組比你現有的更高
[exp-commands_lawnmower]
selection-name=割草機
-description=清理屍體、裝飾和核洞
+description=清理屍體、裝飾和核彈洞。
arg-range=半徑
complete=__1__ 格已完成。
[exp-commands_locate]
-description=在地圖上查找用戶
+description=在地圖上查找用戶的最後位置。
arg-player=用戶
-arg-remote=若在使用地圖查看模式
+arg-remote=若為 true,則開啟在玩家遠端視圖的位置。
response=__1__ 的最後地點是 [gps=__2__,__3__]
[exp-commands_me]
-description=在聊天中發送操作訊息
+description=在聊天中發送操作訊息。
arg-action=你想做的動作。
response=* __1__ __2__ *
@@ -121,17 +121,19 @@ description-entity=啟用建築保護
description-area=啟用區域保護
enter-entity=現在進入建築保護,選擇一個建築來保護,按住 shift 來選擇則可取消。
enter-area=現在進入區域保護,選擇一個區域來保護,按住 shift 來選擇則可取消。
-exit-entity=已進入建築保護
-exit-area=已進入區域選擇
+exit-entity=已進入建築保護。
+exit-area=已進入區域選擇。
protected-entities=__1__ 個建築現已受保護。
unprotected-entities=__1__ 個建築現不受保護。
already-protected=本地區已受保護。
protected-area=本地區現已受保護。
unprotected-area=本地區現不受保護。
repeat-offence=__1__ 在 [gps=__3__,__4__] 拆除了 __2__
+enter-entity-selection = Entered entity selection, select entites to protect, hold shift to remove protection.
+enter-area-selection = Entered area selection, select areas to protect, hold shift to remove protection.
[exp-commands_tag-protection]
-description=切換管理員標記模式,新標記只能由管理員編輯
+description=切換管理員標記模式,新標記只能由管理員編輯。
exit=你已離開管理員地圖標記模式,所有新地圖標記將不受保護。
enter=你已進入管理員地圖標記模式,所有新地圖標記將受保護。
create=你放了一個管理員地圖標記。
@@ -153,6 +155,7 @@ fluid-in=你每秒需要 __1__ 來做 [fluid=__2__]。
item-out=結果: __1__ [item=__2__] 每秒。
fluid-out=結果: __1__ [fluid=__2__] 每秒。
machines=你需要 __1__ 部同樣的機器。
+machine-count = And you will need __1__ machines (with the same speed as this one) for this.
[exp-commands_repair]
selection-name=維修
@@ -172,11 +175,21 @@ arg-player-clear=用戶
arg-reason=原因
arg-from-player=用戶
player-immune=用戶不能被舉報
-self-report=你不能舉報自己
+self-report=你不能舉報自己。
+response=__1__ 因 __2__ 被舉報了。
+response-admin=__1__ 因 __3__ 被 __2__ 舉報了。
+already-reported=你已經舉報過該用戶了,你可以讓管理員清除報告來重寫。
+not-reported=該用戶沒有被舉報的紀錄。
+not-reported-by=該用戶沒有被 __1__ 舉報的紀錄。
+reports-title==該用戶現在的被舉報紀錄:
+player-title=__1__ 有 __2__ 項被舉報的紀錄:
+list-element=__1__: __2__
+removed-all=__1__ 被舉報的所有紀錄已被 __2__ 清除。
+removed=__1__ 被舉報的一個紀錄已被 __2__ 清除。
[exp-commands_roles]
-description-assign=為用戶指配用戶組
-description-unassign=為用戶取消指配用戶組
+description-assign=為用戶指配用戶組。
+description-unassign=為用戶取消指配用戶組。
description-get=顯示所有用戶組
arg-player-assign=指配
arg-player-unassign=取消指配
@@ -199,16 +212,16 @@ title=有 [item=__1__] 的用戶:
result=\n__1__) __2__ 有 __3__ 個。 (__4__)
[exp-commands_spectate]
-description-spectate=切換旁觀者模式
-description-follow=開始在旁觀者模式中關注玩家
+description-spectate=切換旁觀者模式。
+description-follow=開始在旁觀者模式中關注玩家。
arg-player=用戶
-follow-self=你不能追蹤自己
+follow-self=你不能追蹤自己。
[exp-commands_surface]
selection-name=位面
-description-items=清除地面上的物品
-description-blueprints=清除藍圖
-description-blueprints-surface=清除藍圖
+description-items=清除地面上的物品。
+description-blueprints=清除藍圖。
+description-blueprints-surface=清除本位面上的藍圖。
arg-surface=位面
items=__1__ 清除了所有在 __2__ 中掉地上的東西。
blueprints=__1__ 清除了所有在 __2__ 中在地上的藍圖。
@@ -225,9 +238,9 @@ response=__1__ 把 __2__ 個火車設置為自動模式。
description-teleport=將一名玩家傳送到另一名玩家。
description-bring=將一名玩家傳送到你身邊。
description-goto=將你傳送到玩家身邊。
-description-spawn=傳送到出生點。
+description-spawn=傳送到出生點
arg-player-teleport=傳送的玩家,若沒有目標,你會被傳送到這。
-arg-player-from=傳送的玩家
+arg-player-from=傳送的玩家。
arg-player-to=傳送的玩家目標
unavailable=沒找到空間進行傳送
same-player=沒辦法傳送到自己
@@ -379,6 +392,7 @@ data-statistics=數據
data-required=需要
data-misc=雜項
data-format=__1____2__
+welcome-title = Welcome to __1__
[exp-gui_research-milestones]
tooltip-main=研究介面
@@ -410,7 +424,7 @@ data-caption-fastest-launch=最快發射
data-tooltip-fastest-launch=最快發射時間
data-caption-total-rockets=總發射次數
data-tooltip-total-rockets=我方總發射次數
-value-tooltip-total-rockets=__1__% 的火箭發射都在本地圖上。
+value-tooltip-total-rockets=__1__% 的火箭發射都在這張地圖上
data-caption-avg-launch=平均時間
data-tooltip-avg-launch=火箭發射平均時間
data-caption-avg-launch-n=平均時間 __1__
@@ -453,8 +467,8 @@ tooltip-cursor-empty=游標為空
caption-main=工作流程 [img=info]
tooltip-main=工作流程
tooltip-sub=需要完成的工作流程\n- 你可以用富文本來加上圖片 [img=utility/not_enough_repair_packs_icon] 或 [color=blue] 顏色[/color]。
-caption-no-tasks=沒有工作流程
-tooltip-no-tasks=按加號加入工作流程
+caption-no-tasks=沒有工作流程!
+tooltip-no-tasks=按加號加入工作流程!
tooltip-last-edit=最後由 __1__ 在 __2__ 修改
tooltip-close=關閉工作流程
tooltip-new=加入工作流程
@@ -470,41 +484,106 @@ tooltip-edit-none=現沒有被人修改
caption-create-footer=加入工作流程
caption-edit-footer=修改工作流程
caption-view-footer=工作流程細節
+tooltip-confirm = Save changes (minimum of 5 characters long)
+
+[exp-gui_warp-list]
+caption-main=傳送陣清單 [img=info]
+tooltip-main=傳送陣清單
+tooltip-sub=傳送陣在 __1__ 秒內只能使用一次。 亦需在 __2__ 格之內使用\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__
+tooltip-sub-current= - __1__ 你目前的傳送陣;
+tooltip-sub-connected= - __1__ 你可以到本傳送陣;
+tooltip-sub-different= - __1__ 該傳送陣未連接;
+tooltip-sub-cooldown= - __1__ 傳送冷卻中;
+tooltip-sub-not-available= - __1__ 你不在傳送陣範圍;
+tooltip-sub-bypass= - __1__ 你可以傳送到這裏;
+tooltip-add=新建傳送陣
+tooltip-add-too-close=無法新建傳送陣; 太接近 __1__ 了
+add-warning-water=無法新建傳送陣; 太接近水源了, 請移動 __1__ 格又或者填平該地方
+add-warning-entities=無法新建傳送陣; 太接近其他東西, 請移動 __1__ 格又或者拆除該東西
+tooltip-icon=修改傳送陣圖案
+tooltip-last-edit=最後由 __1__ 在 __2__ 更改\n按這在地圖上看
+tooltip-confirm=儲存更改
+tooltip-discard=放棄更改
+tooltip-edit=現被 __1__ 修改中
+tooltip-edit-none=現沒有被人修改
+tooltip-remove=拆除傳送陣
+tooltip-cooldown=請等 __1__ 秒才再次傳送
+tooltip-cooldown-none=你要等 __1__ 秒才可以再次傳送
+tooltip-goto=到 X __1__ Y __2__
+tooltip-goto-bypass=到 X __1__ Y __2__, 繞行模式
+tooltip-goto-bypass-different-network=到 X __1__ Y __2__, 繞行模式 (不同網絡)
+tooltip-goto-same-warp=你已在該傳送陣
+tooltip-goto-different-network=該傳送陣未連接, 你可使用電纜連接
+tooltip-goto-cooldown=傳送冷卻中, 請等候冷卻
+tooltip-goto-disabled=你不在傳送陣, 請移至任意傳送陣
+
+[exp-gui_vlayer]
+tooltip-main=Vlayer 介面
+selection-name=vlayer
+caption-stat-solar=[img=entity/solar-panel] 太陽能板
+tooltip-stat-solar=使用中的太陽能板數量 / 儲存的太陽能板數量
+caption-stat-accumulator=[img=entity/accumulator] 蓄電池
+tooltip-stat-accumulator=使用中的蓄電池數量 / 儲存的蓄電池數量
+caption-stat-surface-area=[virtual-signal=signal-R] 餘下土地
+tooltip-stat-surface-area=餘下土地。 放入堆填來增加
+caption-stat-sustained=[virtual-signal=signal-S] 持續產能
+tooltip-stat-sustained=持續電力產能
+caption-stat-production=[virtual-signal=signal-P] 現時產能
+tooltip-stat-production=現時電力產能
+caption-stat-capacity=[virtual-signal=signal-E] 電池電量
+tooltip-stat-capacity=現時電池電量 / 電池最大容量
+type-energy=電力
+type-circuit=回路
+type-storage-input=放入箱
+type-storage-output=提取箱
+caption-refresh=刷新列表
+caption-view=查看介面
+caption-build=建造介面
+caption-remove=拆除介面
+message-located=__1__ 介面在 __2__
+message-built=__1__ 在 __3__ 建造了 __2__ 介面
+message-removed=__1__ 在 __3__ 拆除了 __2__ 介面
+message-no-chest=沒檢測到鋼箱
+message-chest-not-empty=鋼箱不是空的
+message-multiple=無法建造 __1__ 介面,檢測到多個鋼箱
+message-limit=無法建造 __1__ 介面,超出最大限制
+message-no-space=無法在此建造電力介面,空間不足
+message-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太空平台 vlayer 功能。
[exp_afk-kick]
kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。
[exp_chat-auto-reply]
chat-reply=[Compilatron] __1__
-chat-disallowed=你沒有權限使用這個指令。
-reply-online=現在有 __1__ 人上線。
-reply-players=本地圖現在有 __1__ 人曾上線。
-reply-dev=Cooldude2606 只是本場境的開發者
+chat-disallowed=You can't use global chat commands
+reply-online=現在有 __1__ 人在線
+reply-players=這張地圖上共有__1__名用戶
+reply-dev=Cooldude2606 is a dev for this server and makes the scenario and is not a factorio dev.
reply-softmod=這裹用了自設情境。
reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods.
-reply-blame=責怪 __1__ 吧。
-reply-afk=看看 __1__, 他已掛機 __2__ 。
-reply-evolution=現在敵人進化度為 __1__ 。
+reply-blame=責怪 __1__ 吧!
+reply-afk=看看 __1__,他的掛機時長為 __2__
+reply-evolution=現在敵人進化度為 __1__
reply-magic=Fear the admin magic (ノ゚∀゚)ノ⌒・*:.。. .。.:*・゜゚・*☆
reply-aids=≖ ‿ ≖ Fear the aids of a public server ≖ ‿ ≖
reply-riot=(admins) ┬┴┬┴┤ᵒ_ᵒ)├┬┴┬┴ ‹ ‹\(´ω` )/››‹‹\ ( ´)/››‹‹\ ( ´ω`)/›› (rest of server)
-reply-loops=架設迴旋處最終後果都不好。
+reply-loops=NO LOOPS; LOOPS ARE BAD; JUST NO LOOPS!!!; IF YOU MAKE A LOOP… IT WILL NOT END WELL!!!
reply-lenny=( ͡° ͜ʖ ͡°)
reply-hodor=Hodor
-reply-popcorn-1=Heating the oil and waiting for the popping sound...
+reply-popcorn-1=Heating the oil and waiting for the popping sound…
reply-popcorn-2=__1__ your popcorn is finished. Lean backwards and watch the drama unfold.
-reply-snaps-1=Pouring the glasses and finding the correct song book...
-reply-snaps-2=Singing a song...🎤🎶
+reply-snaps-1=Pouring the glasses and finding the correct song book…
+reply-snaps-2=Singing a song…🎤🎶
reply-snaps-3=schkål, my friends!
-reply-cocktail-1= 🍸 Inintiating mind reading unit... 🍸
+reply-cocktail-1= 🍸 Inintiating mind reading unit… 🍸
reply-cocktail-2= 🍸 Mixing favourite ingredients of __1__ 🍸
reply-cocktail-3=🍸 __1__ your cocktail is done.🍸
-reply-coffee-1= ☕ Boiling the water and grinding the coffee beans... ☕
+reply-coffee-1= ☕ Boiling the water and grinding the coffee beans… ☕
reply-coffee-2= ☕ __1__ we ran out of coffe beans! Have some tea instead. ☕
-reply-pizza-1= 🍕 Finding nearest pizza supplier... 🍕
+reply-pizza-1= 🍕 Finding nearest pizza supplier… 🍕
reply-pizza-2= 🍕 Figuring out the favourite pizza of __1__ 🍕
reply-pizza-3= 🍕 __1__ your pizza is here! 🍕
-reply-tea-1= ☕ Boiling the water... ☕
+reply-tea-1= ☕ Boiling the water… ☕
reply-tea-2= ☕ __1__ your tea is done! ☕
reply-mead-1= Filling the drinking horn
reply-mead-2= Skål!
@@ -536,3 +615,22 @@ chat-jailed=__1__ 因被多次拆除受保護物體而被禁止行動。請等
[exp_report-jail]
chat-jailed=__1__ 因被多次舉報而被禁止行動。請等候管理員作出下一步處理。
+
+
+[exp-gui_player-list]
+main-tooltip = Player List
+open-action-bar = Options
+close-action-bar = Close Options
+reason-confirm = Confirm Reason
+reason-entry = Enter Reason
+goto-player = Goto player
+bring-player = Bring player
+report-player = Report player
+jail-player = Jail player
+kick-player = Kick player
+ban-player = Ban player
+afk-time = __1__% of total map time\nLast moved __2__ ago
+open-map = __1__ __2__\n__3__\nClick to open map
+
+[exp_spectate]
+follow-label = Following __1__.\nClick here or press escape to stop following.
\ No newline at end of file
diff --git a/exp_scenario/package.json b/exp_scenario/package.json
index 8a00f8b4..706fef28 100644
--- a/exp_scenario/package.json
+++ b/exp_scenario/package.json
@@ -5,17 +5,22 @@
"author": "Cooldude2606 ",
"license": "MIT",
"repository": "explosivegaming/ExpCluster",
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
- "prepare": "tsc --build && webpack-cli --env production"
+ "prepare": "tsc --build && webpack-cli --env production",
+ "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js",
+ "coverage": "tap --coverage-report=text test/*.test.js"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
+ "@clusterio/controller": "workspace:^",
"@clusterio/lib": "workspace:^"
},
"devDependencies": {
+ "@clusterio/controller": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@types/node": "catalog:",
@@ -23,6 +28,7 @@
"antd": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
+ "tap": "^21.1.0",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
@@ -32,6 +38,8 @@
"@expcluster/lib_commands": "workspace:^",
"@expcluster/lib_util": "workspace:^",
"@expcluster/lib_gui": "workspace:^",
+ "@expcluster/permission-groups": "workspace:^",
+ "@expcluster/roles": "workspace:^",
"@sinclair/typebox": "catalog:"
},
"publishConfig": {
diff --git a/exp_scenario/permissions.ts b/exp_scenario/permissions.ts
index bbaafacd..5fc7aa52 100644
--- a/exp_scenario/permissions.ts
+++ b/exp_scenario/permissions.ts
@@ -1,4 +1,4 @@
-import * as lib from "@clusterio/lib";
+import type * as lib from "@clusterio/lib";
/**
* Permissions checked by the scenario in game through exp_roles.
@@ -7,9 +7,9 @@ import * as lib from "@clusterio/lib";
* underscores, see module/commands/_authorities.lua. Everything else is checked
* by name at its call site.
*/
-type Definition = [name: string, title: string, description: string, grantByDefault?: boolean];
+type Definition = readonly [name: Name, title: string, description: string, grantByDefault?: boolean];
-const definitions: Definition[] = [
+const definitions = [
["exp_scenario.bypass.deconstruction_log", "Deconstruction log bypass", "Be excluded from the deconstruction log."],
["exp_scenario.bypass.entity_protection", "Bypass entity protection", "Remove entities that the protection filter would block."],
["exp_scenario.bypass.nuke_protection", "Bypass nuke protection", "Use nukes without the nuke protection restrictions."],
@@ -118,8 +118,12 @@ const definitions: Definition[] = [
["exp_scenario.player.admin", "Factorio admin", "Be promoted to Factorio admin while holding a role with this permission."],
["exp_scenario.player.instant_respawn", "Instant respawn", "Respawn after two seconds instead of the default delay."],
["exp_scenario.player.spectator", "Spectator", "Remove the zoom to world noise effect, as Factorio does for spectators."],
-];
+] as const satisfies readonly Definition[];
-for (const [name, title, description, grantByDefault] of definitions) {
- lib.definePermission({ name, title, description, grantByDefault });
-}
+/** Name of a permission defined by the scenario, added to lib.Permissions in index.ts. */
+export type ScenarioPermissionName = (typeof definitions)[number][0];
+
+export const permissions = definitions.map((definition: Definition): lib.PermissionDefinition => {
+ const [name, title, description, grantByDefault] = definition;
+ return { name, title, description, grantByDefault };
+});
diff --git a/exp_roles/seed.ts b/exp_scenario/seed.ts
similarity index 75%
rename from exp_roles/seed.ts
rename to exp_scenario/seed.ts
index b028314a..64b34550 100644
--- a/exp_roles/seed.ts
+++ b/exp_scenario/seed.ts
@@ -1,4 +1,5 @@
-import { RoleColor } from "./messages";
+import type * as lib from "@clusterio/lib";
+import { RoleColor } from "@expcluster/roles";
/**
* A role created by the seed, as the scenario defined it before roles moved to
@@ -19,7 +20,23 @@ export interface SeedRole {
isAdmin?: boolean;
/** Name of the role whose permissions are also granted, applied recursively. */
parent?: string;
- permissions: string[];
+ permissions: lib.PermissionName[];
+ /**
+ * Name of the seed group holders are placed in by their highest role.
+ * Without one the holders stay in Factorio's Default group.
+ */
+ group?: string;
+}
+
+/**
+ * A Factorio permission group created by the seed, as the scenario defined it
+ * before groups moved to the controller.
+ */
+export interface SeedGroup {
+ name: string;
+ /** When true the input actions are the only ones disallowed, otherwise the only ones allowed. */
+ isBlacklist: boolean;
+ inputActions: string[];
}
export const seedRoles: SeedRole[] = [
@@ -32,6 +49,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Senior Administrator",
+ group: "Admin",
shortHand: "SAdmin",
color: new RoleColor(233, 63, 233),
parent: "Administrator",
@@ -44,6 +62,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Administrator",
+ group: "Admin",
shortHand: "Admin",
color: new RoleColor(233, 63, 233),
parent: "Moderator",
@@ -55,6 +74,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Moderator",
+ group: "Admin",
shortHand: "Mod",
color: new RoleColor(0, 170, 0),
parent: "Trainee",
@@ -89,6 +109,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Trainee",
+ group: "Admin",
shortHand: "TrMod",
color: new RoleColor(0, 170, 0),
parent: "Veteran",
@@ -120,6 +141,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Board Member",
+ group: "Trusted",
shortHand: "Board",
color: new RoleColor(247, 246, 54),
parent: "Sponsor",
@@ -133,6 +155,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Senior Backer",
+ group: "Trusted",
shortHand: "Backer",
color: new RoleColor(238, 172, 44),
parent: "Sponsor",
@@ -140,6 +163,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Sponsor",
+ group: "Trusted",
shortHand: "Spon",
color: new RoleColor(238, 172, 44),
parent: "Supporter",
@@ -158,6 +182,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Supporter",
+ group: "Trusted",
shortHand: "Sup",
color: new RoleColor(230, 99, 34),
parent: "Veteran",
@@ -172,6 +197,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Partner",
+ group: "Trusted",
shortHand: "Part",
color: new RoleColor(140, 120, 200),
parent: "Veteran",
@@ -183,6 +209,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Veteran",
+ group: "Trusted",
shortHand: "Vet",
color: new RoleColor(140, 120, 200),
parent: "Member",
@@ -196,6 +223,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Member",
+ group: "Standard",
shortHand: "Mem",
color: new RoleColor(24, 172, 188),
parent: "Regular",
@@ -217,6 +245,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Regular",
+ group: "Standard",
shortHand: "Reg",
color: new RoleColor(79, 155, 163),
autoAssignHours: 3,
@@ -232,6 +261,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Jail",
+ group: "Restricted",
shortHand: "Jail",
color: new RoleColor(50, 50, 50),
priority: 1,
@@ -240,6 +270,7 @@ export const seedRoles: SeedRole[] = [
},
{
name: "Guest",
+ group: "Guest",
shortHand: "",
color: new RoleColor(185, 187, 160),
isDefault: true,
@@ -247,9 +278,59 @@ export const seedRoles: SeedRole[] = [
},
];
+const adminDisallowed = [
+ "add_permission_group",
+ "delete_permission_group",
+ "edit_permission_group",
+ "import_permissions_string",
+ "map_editor_action",
+ "toggle_map_editor",
+ "change_multiplayer_config",
+ "set_heat_interface_mode",
+ "set_heat_interface_temperature",
+ "set_infinity_container_filter_item",
+ "set_infinity_container_remove_unfiltered_items",
+ "set_infinity_pipe_filter",
+];
+
+const trustedDisallowed = [
+ ...adminDisallowed,
+ "admin_action",
+];
+
+const standardDisallowed = [
+ ...trustedDisallowed,
+ "change_programmable_speaker_alert_parameters",
+ "drop_item",
+ "open_new_platform_button_from_rocket_silo",
+ "set_rocket_silo_send_to_orbit_automated_mode",
+];
+
+const guestDisallowed = [
+ ...standardDisallowed,
+ "change_programmable_speaker_parameters",
+ "change_train_stop_station",
+ "remove_cables",
+ "remove_train_station",
+ "reset_assembling_machine",
+ "rotate_entity",
+ "launch_rocket",
+ "cancel_research",
+ "flush_opened_entity_fluid",
+ "flush_opened_entity_specific_fluid",
+];
+
+export const seedGroups: SeedGroup[] = [
+ { name: "Admin", isBlacklist: true, inputActions: adminDisallowed },
+ { name: "Trusted", isBlacklist: true, inputActions: trustedDisallowed },
+ { name: "Standard", isBlacklist: true, inputActions: standardDisallowed },
+ { name: "Guest", isBlacklist: true, inputActions: guestDisallowed },
+ { name: "Restricted", isBlacklist: false, inputActions: ["write_to_console"] },
+];
+
/** The permissions a seed role grants, including those of its parents. */
export function flattenSeedPermissions(role: SeedRole, roles = seedRoles) {
- const permissions = new Set();
+ const permissions = new Set();
const seen = new Set();
let current: SeedRole | undefined = role;
while (current && !seen.has(current.name)) {
diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js
new file mode 100644
index 00000000..097365ab
--- /dev/null
+++ b/exp_scenario/test/controller.test.js
@@ -0,0 +1,139 @@
+import t from "tap";
+import * as lib from "@clusterio/lib";
+import { Controller } from "@clusterio/controller";
+import { ControllerPlugin } from "../dist/node/controller.js";
+import * as messages from "../dist/node/messages.js";
+import { seedRoles, seedGroups } from "../dist/node/seed.js";
+import * as roles from "@expcluster/roles";
+import * as groups from "@expcluster/permission-groups";
+import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js";
+import { ControllerPlugin as GroupsPlugin } from "@expcluster/permission-groups/dist/node/controller.js";
+import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups";
+
+import { plugin } from "../dist/node/index.js";
+
+// Registering the plugin defines the permissions the seed grants
+lib.registerPluginPermissions([plugin]);
+
+// The controller validates message classes against the link registry
+for (const Message of [messages.SeedRequest, ...roles.plugin.messages, ...groups.plugin.messages]) {
+ lib.Link.register(Message);
+}
+
+const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
+
+// Silence the singleton logger to avoid polluting the test output
+lib.logger.silent = true;
+t.after(() => {
+ lib.logger.silent = false;
+});
+
+/** Build the plugin around a real controller, which is side effect free while not started. */
+async function startPlugin(t2, { withPlugins = true } = {}) {
+ const controllerConfig = new lib.ControllerConfig("controller", {
+ "controller.database_directory": t2.testdir(),
+ "controller.default_role_id": lib.Role.DefaultPlayerRoleId,
+ });
+ const controller = new Controller(logger, [], controllerConfig);
+ controller.roles.set(new lib.Role(0, "Cluster Admin", "", new Set(["core.admin"])));
+ controller.roles.set(new lib.Role(1, "Player", "", new Set()));
+
+ if (withPlugins) {
+ for (const [name, Plugin] of [["exp_roles", RolesPlugin], ["exp_groups", GroupsPlugin]]) {
+ const plugin = new Plugin({ name }, controller, undefined, logger);
+ await plugin.init();
+ }
+ }
+
+ const plugin = new ControllerPlugin({ name: "exp_scenario" }, controller, undefined, logger);
+ await plugin.init();
+ return { plugin, controller };
+}
+
+const byName = (datastore, name) => [...datastore.values()].find(other => other.name === name);
+
+t.test("class ControllerPlugin", t2 => {
+ t2.test(".handleSeedRequest() throws when the seeded plugins are missing", async t3 => {
+ const { plugin } = await startPlugin(t3, { withPlugins: false });
+ await t3.rejects(
+ plugin.handleSeedRequest(),
+ { message: "Seeding requires the exp_roles and exp_groups plugins" },
+ "the request is rejected",
+ );
+ });
+
+ t2.test(".handleSeedRequest() creates the roles and reuses them by name", async t3 => {
+ const { plugin, controller } = await startPlugin(t3);
+ const rolesPlugin = RolesPlugin.get(controller);
+
+ await plugin.handleSeedRequest();
+ t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists");
+
+ const moderator = byName(controller.roles, "Moderator");
+ t3.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in");
+ t3.strictSame(rolesPlugin.roleMeta.get(moderator.id).shortHand, "Mod", "the role properties match the seed");
+
+ await plugin.handleSeedRequest();
+ t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles");
+ });
+
+ t2.test(".handleSeedRequest() creates the groups and resets them by name", async t3 => {
+ const { plugin, controller } = await startPlugin(t3);
+ const groupsPlugin = GroupsPlugin.get(controller);
+
+ await plugin.handleSeedRequest();
+ t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "every seed group exists");
+
+ const restricted = byName(groupsPlugin.groups, "Restricted");
+ t3.strictSame(restricted.permissions.isBlacklist, false, "the group only allows the listed actions");
+ t3.strictSame(restricted.permissions.permissions, ["write_to_console"], "the actions match the seed");
+
+ const admin = byName(groupsPlugin.groups, "Admin");
+ groupsPlugin.groups.set(new GroupRecord(admin.id, admin.name, new GroupPermissions(true, [])));
+ await plugin.handleSeedRequest();
+ t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "seeding again reuses the groups");
+ t3.ok(
+ byName(groupsPlugin.groups, "Admin").permissions.permissions.includes("toggle_map_editor"),
+ "seeding again resets the actions",
+ );
+ });
+
+ t2.test(".handleSeedRequest() maps each role onto its group by rank", async t3 => {
+ const { plugin, controller } = await startPlugin(t3);
+ const groupsPlugin = GroupsPlugin.get(controller);
+
+ await plugin.handleSeedRequest();
+ const mapped = seedRoles.filter(role => role.group !== undefined);
+ t3.strictSame(groupsPlugin.roleMappings.size, mapped.length, "every role with a group is mapped");
+
+ const mappingOf = name => [...groupsPlugin.roleMappings.values()]
+ .find(mapping => mapping.roleIds.has(byName(controller.roles, name).id));
+ const groupOf = name => groupsPlugin.groups.get(mappingOf(name).groupId).name;
+ t3.strictSame(groupOf("Player"), "Guest", "the default role maps to the guest group");
+ t3.strictSame(groupOf("Jail"), "Restricted", "jail maps to the restricted group");
+ t3.ok(mappingOf("Jail").priority > mappingOf("Senior Administrator").priority, "jail outranks every role");
+ t3.ok(mappingOf("Moderator").priority > mappingOf("Veteran").priority, "the role order sets the rank");
+ t3.ok(mappingOf("Veteran").priority > mappingOf("Player").priority, "the default role ranks lowest");
+ t3.strictSame(mappingOf("Cluster Admin"), undefined, "the admin role keeps the factorio default group");
+
+ const priorities = [...groupsPlugin.roleMappings.values()].map(mapping => mapping.priority);
+ t3.strictSame(priorities.length, new Set(priorities).size, "priorities are unique");
+ });
+
+ t2.test(".handleSeedRequest() reuses mappings and keeps clear of other mappings", async t3 => {
+ const { plugin, controller } = await startPlugin(t3);
+ const groupsPlugin = GroupsPlugin.get(controller);
+
+ groupsPlugin.roleMappings.set(new RoleMappingRecord(99, new Set([0, 1]), 5, 2, true));
+ await plugin.handleSeedRequest();
+ const first = new Map([...groupsPlugin.roleMappings.values()].map(mapping => [mapping.id, mapping.priority]));
+
+ await plugin.handleSeedRequest();
+ const second = new Map([...groupsPlugin.roleMappings.values()].map(mapping => [mapping.id, mapping.priority]));
+ t3.strictSame(second, first, "seeding again reuses the mappings");
+ t3.strictSame(groupsPlugin.roleMappings.get(99).priority, 2, "other mappings are left alone");
+ t3.notOk([...first.values()].filter(priority => priority === 2).length > 1, "seed priorities skip taken ones");
+ });
+
+ t2.end();
+});
diff --git a/exp_roles/test/seed.test.js b/exp_scenario/test/seed.test.js
similarity index 63%
rename from exp_roles/test/seed.test.js
rename to exp_scenario/test/seed.test.js
index adcb0987..c201409d 100644
--- a/exp_roles/test/seed.test.js
+++ b/exp_scenario/test/seed.test.js
@@ -1,10 +1,11 @@
-"use strict";
-const t = require("tap");
-const lib = require("@clusterio/lib");
-const { seedRoles, flattenSeedPermissions } = require("../dist/node/seed");
+import t from "tap";
+import * as lib from "@clusterio/lib";
+import { seedRoles, seedGroups, flattenSeedPermissions } from "../dist/node/seed.js";
-// Importing this defines the exp_scenario permissions the seed grants
-require("@expcluster/scenario/dist/node/permissions");
+import { plugin } from "../dist/node/index.js";
+
+// Registering the plugin defines the permissions the seed grants
+lib.registerPluginPermissions([plugin]);
t.test("seedRoles[] grant only defined permissions", t2 => {
for (const role of seedRoles) {
@@ -23,6 +24,29 @@ t.test("seedRoles[] are unique with one default and one admin", t2 => {
t2.end();
});
+t.test("seedRoles[] are placed in defined groups", t2 => {
+ const groupNames = new Set(seedGroups.map(group => group.name));
+ for (const role of seedRoles) {
+ if (role.group !== undefined) {
+ t2.ok(groupNames.has(role.group), `${role.name} is placed in defined group ${role.group}`);
+ }
+ }
+ t2.end();
+});
+
+t.test("seedGroups[] are unique", t2 => {
+ const names = seedGroups.map(group => group.name);
+ t2.strictSame(names.length, new Set(names).size, "names are unique");
+ for (const group of seedGroups) {
+ t2.strictSame(
+ group.inputActions.length,
+ new Set(group.inputActions).size,
+ `${group.name} lists each input action once`,
+ );
+ }
+ t2.end();
+});
+
t.test("flattenSeedPermissions() inherits permissions from parent roles", t2 => {
const byName = new Map(seedRoles.map(role => [role.name, role]));
for (const role of seedRoles) {
diff --git a/exp_scenario/tsconfig.browser.json b/exp_scenario/tsconfig.browser.json
index 1e3889e7..b67577c5 100644
--- a/exp_scenario/tsconfig.browser.json
+++ b/exp_scenario/tsconfig.browser.json
@@ -1,4 +1,4 @@
{
"extends": "../tsconfig.browser.json",
- "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
+ "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "permissions.ts", "package.json" ],
}
diff --git a/exp_scenario/tsconfig.node.json b/exp_scenario/tsconfig.node.json
index 3218f2e7..f3a03479 100644
--- a/exp_scenario/tsconfig.node.json
+++ b/exp_scenario/tsconfig.node.json
@@ -1,5 +1,9 @@
{
"extends": "../tsconfig.node.json",
+ "references": [
+ { "path": "../exp_groups/tsconfig.node.json" },
+ { "path": "../exp_roles/tsconfig.node.json" },
+ ],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}
diff --git a/exp_scenario/web/components/Seed.tsx b/exp_scenario/web/components/Seed.tsx
new file mode 100644
index 00000000..8131b137
--- /dev/null
+++ b/exp_scenario/web/components/Seed.tsx
@@ -0,0 +1,33 @@
+import React, { useContext, useState } from "react";
+import { Button, Popconfirm } from "antd";
+
+import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui";
+
+import { SeedRequest } from "../../messages.js";
+
+/** Button on the roles page which creates the roles and permission groups the scenario shipped with. */
+export default function Seed() {
+ const control = useContext(ControlContext);
+ const account = useAccount();
+ const [seeding, setSeeding] = useState(false);
+
+ if (!account.hasPermission("exp_scenario.seed")) {
+ return null;
+ }
+
+ return {
+ setSeeding(true);
+ control.send(new SeedRequest())
+ .catch(notifyErrorHandler("Error seeding roles and groups"))
+ .finally(() => setSeeding(false));
+ }}
+ >
+
+ }
+ />;
+}
diff --git a/exp_scenario/web/index.tsx b/exp_scenario/web/index.tsx
index e69de29b..cecbd7f0 100644
--- a/exp_scenario/web/index.tsx
+++ b/exp_scenario/web/index.tsx
@@ -0,0 +1,11 @@
+import { BaseWebPlugin } from "@clusterio/web_ui";
+
+import Seed from "./components/Seed";
+
+export class WebPlugin extends BaseWebPlugin {
+ async init() {
+ this.componentExtra = {
+ RolesPage: Seed,
+ };
+ }
+}
diff --git a/exp_scenario/webpack.config.js b/exp_scenario/webpack.config.cjs
similarity index 100%
rename from exp_scenario/webpack.config.js
rename to exp_scenario/webpack.config.cjs
diff --git a/exp_server_ups/index.ts b/exp_server_ups/index.ts
index cdfc8663..404259e9 100644
--- a/exp_server_ups/index.ts
+++ b/exp_server_ups/index.ts
@@ -12,7 +12,7 @@ export const plugin: lib.PluginDeclaration = {
title: "ExpGaming - Server UPS",
description: "Clusterio plugin providing in game server ups counter",
- instanceEntrypoint: "./dist/node/instance",
+ instanceEntrypoint: "./dist/node/instance.js",
instanceConfigFields: {
"exp_server_ups.update_interval": {
title: "Update Interval",
diff --git a/exp_server_ups/module/locale/zh-CN.cfg b/exp_server_ups/module/locale/zh-CN.cfg
index a92404d3..4a9758a1 100644
--- a/exp_server_ups/module/locale/zh-CN.cfg
+++ b/exp_server_ups/module/locale/zh-CN.cfg
@@ -1,2 +1,2 @@
[exp_server-ups]
-description=啟動 UPS 顯示
\ No newline at end of file
+description=啟動 UPS 顯示。
\ No newline at end of file
diff --git a/exp_server_ups/module/locale/zh-TW.cfg b/exp_server_ups/module/locale/zh-TW.cfg
index a92404d3..4a9758a1 100644
--- a/exp_server_ups/module/locale/zh-TW.cfg
+++ b/exp_server_ups/module/locale/zh-TW.cfg
@@ -1,2 +1,2 @@
[exp_server-ups]
-description=啟動 UPS 顯示
\ No newline at end of file
+description=啟動 UPS 顯示。
\ No newline at end of file
diff --git a/exp_server_ups/package.json b/exp_server_ups/package.json
index 25baa73f..e2a01051 100644
--- a/exp_server_ups/package.json
+++ b/exp_server_ups/package.json
@@ -5,6 +5,7 @@
"author": "Cooldude2606 ",
"license": "MIT",
"repository": "explosivegaming/ExpCluster",
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production"
diff --git a/exp_server_ups/webpack.config.js b/exp_server_ups/webpack.config.cjs
similarity index 100%
rename from exp_server_ups/webpack.config.js
rename to exp_server_ups/webpack.config.cjs
diff --git a/exp_util/index.ts b/exp_util/index.ts
index c169aefd..12957919 100644
--- a/exp_util/index.ts
+++ b/exp_util/index.ts
@@ -4,5 +4,5 @@ export const plugin: lib.PluginDeclaration = {
name: "exp_util",
title: "ExpGaming Module Utilities",
description: "Provides extensions and overrides of base Lua library functions, and provides utility modules for improved module compatibly",
- instanceEntrypoint: "./dist/node/instance",
+ instanceEntrypoint: "./dist/node/instance.js",
};
diff --git a/exp_util/module/locale/zh-CN.cfg b/exp_util/module/locale/zh-CN.cfg
index fc488718..6d7d17d0 100644
--- a/exp_util/module/locale/zh-CN.cfg
+++ b/exp_util/module/locale/zh-CN.cfg
@@ -1,5 +1,5 @@
[exp_util]
time-symbol-days-short=__1__日
rich-text-color-tag=[color=__1__,__2__,__3__]__4__[/color]
-selection_enter=現在進入 __1__ 區域選擇
-selection_exit=已進入 __1__ 區域選擇
+selection_enter=現在進入 __1__ 區域選擇。
+selection_exit=已進入 __1__ 區域選擇。
diff --git a/exp_util/module/locale/zh-TW.cfg b/exp_util/module/locale/zh-TW.cfg
index fc488718..6d7d17d0 100644
--- a/exp_util/module/locale/zh-TW.cfg
+++ b/exp_util/module/locale/zh-TW.cfg
@@ -1,5 +1,5 @@
[exp_util]
time-symbol-days-short=__1__日
rich-text-color-tag=[color=__1__,__2__,__3__]__4__[/color]
-selection_enter=現在進入 __1__ 區域選擇
-selection_exit=已進入 __1__ 區域選擇
+selection_enter=現在進入 __1__ 區域選擇。
+selection_exit=已進入 __1__ 區域選擇。
diff --git a/exp_util/package.json b/exp_util/package.json
index ec10ff5d..3dfa39e2 100644
--- a/exp_util/package.json
+++ b/exp_util/package.json
@@ -5,6 +5,7 @@
"author": "Cooldude2606 ",
"license": "MIT",
"repository": "explosivegaming/ExpCluster",
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production"
diff --git a/exp_util/webpack.config.js b/exp_util/webpack.config.cjs
similarity index 100%
rename from exp_util/webpack.config.js
rename to exp_util/webpack.config.cjs
diff --git a/test/common.js b/test/common.js
index bf63b030..f6c476f5 100644
--- a/test/common.js
+++ b/test/common.js
@@ -1,5 +1,4 @@
-"use strict";
-const { compile } = require("@clusterio/lib");
+import { compile } from "@clusterio/lib";
/**
* Generate a flat array of tests from a matrix of inputs.
@@ -49,7 +48,7 @@ function testRoundTripJsonSerialisable(t, Class, tests) {
}
}
-module.exports = {
+export {
testMatrix,
testRoundTripJsonSerialisable,
};
diff --git a/test/lua/runner.js b/test/lua/runner.js
index 51019609..14dbb9d0 100644
--- a/test/lua/runner.js
+++ b/test/lua/runner.js
@@ -1,7 +1,6 @@
-"use strict";
-const path = require("node:path");
-const fs = require("node:fs");
-const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari");
+import path from "node:path";
+import fs from "node:fs";
+import { createRequire } from "node:module";
/**
* Run one lua test file in its own lua state and return its results.
@@ -21,6 +20,8 @@ const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari");
* @returns {{ name: string, ok: boolean, detail?: string }[]}
*/
function runLuaTests(envFile, testFile) {
+ // fengari is a devDependency of the plugin whose tests run, so resolve it from there
+ const { lua, lauxlib, lualib, to_luastring, to_jsstring } = createRequire(testFile)("fengari");
const L = lauxlib.luaL_newstate();
lualib.luaL_openlibs(L);
@@ -38,7 +39,7 @@ function runLuaTests(envFile, testFile) {
};
load(envFile);
- lua.lua_pushstring(L, to_luastring(__dirname));
+ lua.lua_pushstring(L, to_luastring(import.meta.dirname));
call();
// The environment stays on the stack and is passed to the test chunk
@@ -60,4 +61,4 @@ function reportLuaTests(t, envFile, testFile) {
t.end();
}
-module.exports = { runLuaTests, reportLuaTests };
+export { runLuaTests, reportLuaTests };
diff --git a/tsconfig.json b/tsconfig.json
index 420ba647..68f733fd 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,7 @@
{
"files": [],
"references": [
+ { "path": "./exp_auto_restart/" },
{ "path": "./exp_commands/" },
{ "path": "./exp_groups/" },
{ "path": "./exp_gui/" },
diff --git a/tsconfig.node.json b/tsconfig.node.json
index a2e84c8b..b5f49789 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -2,7 +2,7 @@
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["es2023"],
- "module": "node16",
+ "module": "nodenext",
"moduleResolution": "node16",
"target": "es2022",
"forceConsistentCasingInFileNames": true,