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 -translation status -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). + +

translation status

## 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,