From 88358b23b9c65d03a49da7361e3d70ff42772329 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:56:14 +0000 Subject: [PATCH 01/11] Add a web page for configuring scenario features Features can now be enabled, disabled, and configured from the web UI while the game is running, without editing Lua files. The first five features covered are AFK Kick, Chat Auto Reply, Death Markers, Mine Depletion, and Station Auto Name. Lua side: module/features.lua holds each feature's defaults, keeps the overrides from the controller in storage, and patches them onto the config table in place. Features.guard skips a feature's handlers while it is disabled. The five config files move from exp_legacy into exp_scenario/module/config and their control files now read config at runtime instead of deciding at load which handlers to register. AFK Kick times are now in minutes. Controller side: features.ts lists the editable settings and their defaults. Overrides that differ from the default are stored in exp_scenario/features.json and guarded by the existing exp_scenario.config.view/edit permissions. The instance plugin sends them over rcon on start, on every change, and again after the controller restarts. Co-Authored-By: Claude Opus 5.5 --- exp_legacy/module/config/afk_kick.lua | 14 -- .../module/config/station_auto_name.lua | 11 - exp_scenario/controller.ts | 56 +++++ exp_scenario/features.ts | 222 ++++++++++++++++++ exp_scenario/index.ts | 6 + exp_scenario/instance.ts | 80 ++++++- exp_scenario/messages.ts | 121 ++++++++++ exp_scenario/module/config/afk_kick.lua | 18 ++ .../module/config/chat_auto_reply.lua | 7 +- .../module/config/death_markers.lua | 16 +- .../module/config/mine_depletion.lua | 10 +- .../module/config/station_auto_name.lua | 16 ++ exp_scenario/module/control/afk_kick.lua | 17 +- .../module/control/chat_auto_reply.lua | 13 +- exp_scenario/module/control/death_markers.lua | 38 ++- .../module/control/mine_depletion.lua | 7 +- .../module/control/station_auto_name.lua | 9 +- exp_scenario/module/features.lua | 135 +++++++++++ exp_scenario/module/globals.lua | 6 + exp_scenario/module/module.json | 1 + exp_scenario/package.json | 3 + exp_scenario/test/controller.test.js | 49 +++- exp_scenario/test/features.test.js | 51 ++++ exp_scenario/test/instance.test.js | 120 ++++++++++ exp_scenario/test/module.test.js | 8 + exp_scenario/test/module/env.lua | 46 ++++ exp_scenario/test/module/features.lua | 63 +++++ exp_scenario/tsconfig.browser.json | 2 +- exp_scenario/web/components/FeaturesPage.tsx | 147 ++++++++++++ exp_scenario/web/index.tsx | 23 ++ 30 files changed, 1226 insertions(+), 89 deletions(-) delete mode 100644 exp_legacy/module/config/afk_kick.lua delete mode 100644 exp_legacy/module/config/station_auto_name.lua create mode 100644 exp_scenario/features.ts create mode 100644 exp_scenario/module/config/afk_kick.lua rename exp_legacy/module/config/chat_reply.lua => exp_scenario/module/config/chat_auto_reply.lua (98%) rename exp_legacy/module/config/death_logger.lua => exp_scenario/module/config/death_markers.lua (64%) rename exp_legacy/module/config/miner.lua => exp_scenario/module/config/mine_depletion.lua (62%) create mode 100644 exp_scenario/module/config/station_auto_name.lua create mode 100644 exp_scenario/module/features.lua create mode 100644 exp_scenario/module/globals.lua create mode 100644 exp_scenario/test/features.test.js create mode 100644 exp_scenario/test/instance.test.js create mode 100644 exp_scenario/test/module.test.js create mode 100644 exp_scenario/test/module/env.lua create mode 100644 exp_scenario/test/module/features.lua create mode 100644 exp_scenario/web/components/FeaturesPage.tsx diff --git a/exp_legacy/module/config/afk_kick.lua b/exp_legacy/module/config/afk_kick.lua deleted file mode 100644 index 3e52552a..00000000 --- a/exp_legacy/module/config/afk_kick.lua +++ /dev/null @@ -1,14 +0,0 @@ -local Roles = require("modules/exp_roles") - -return { - admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time - trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time - afk_time = 3600 * 10, --- @setting afk_time The time in ticks that must pass for a player to be considered afk - kick_time = 3600 * 30, --- @setting kick_time The time in ticks that must pass without any active players for all players to be kicked - trust_time = 3600 * 60 * 10, --- @setting trust_time The time in ticks that a player must be online for to count as trusted - update_time = 3600 * 30, --- @setting update_time How often in ticks the script checks for active players - custom_active_check = function(player) - local veteran = Roles.get_role_by_name("Veteran") - return veteran ~= nil and not Roles.get_player_highest_role(player):is_lower_than(veteran) - end, -} diff --git a/exp_legacy/module/config/station_auto_name.lua b/exp_legacy/module/config/station_auto_name.lua deleted file mode 100644 index 60f93124..00000000 --- a/exp_legacy/module/config/station_auto_name.lua +++ /dev/null @@ -1,11 +0,0 @@ -return { - --[[ - __icon__ - __item_name__ - __backer_name__ - __direction__ - __x__ - __y__ - ]] - station_name = "[L] __icon__", -} diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 2914f98c..88295769 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -1,23 +1,79 @@ import * as lib from "@clusterio/lib"; +import * as path from "node:path"; import type { Controller, ControllerPluginContext } 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 { features, validateFeatureValues } from "./features.js"; import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed.js"; export class ControllerPlugin { controller: Controller; logger: lib.Logger; + name: string; + features!: lib.SubscribableDatastore; constructor(context: ControllerPluginContext) { this.controller = context.controller; this.logger = context.logger; + this.name = context.plugin.name; } async init() { + const databaseDirectory = this.controller.config.get("controller.database_directory"); + this.features = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_scenario", "features.json"), + messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord), + ).bootstrap() + ); + + // Features added since the last start begin with their defaults + const missing = features.filter(feature => !this.features.has(feature.name)); + if (missing.length) { + this.features.setMany(missing.map(feature => new messages.FeatureRecord(feature.name, true))); + } + + this.controller.subscriptions.handle(messages.FeatureUpdatedEvent, this.handleFeatureSubscription.bind(this)); + this.features.on("update", this.featuresUpdated.bind(this)); + this.controller.handle(messages.SeedRequest, this.handleSeedRequest.bind(this)); + this.controller.handle(messages.FeatureListRequest, this.handleFeatureListRequest.bind(this)); + this.controller.handle(messages.FeatureUpdateRequest, this.handleFeatureUpdateRequest.bind(this)); + + this.controller.hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); + } + + async onShutdown() { + await this.features.save(); + } + + featuresUpdated(updates: messages.FeatureRecord[]) { + this.controller.subscriptions.broadcast(new messages.FeatureUpdatedEvent(updates)); + } + + async handleFeatureSubscription(request: lib.SubscriptionRequest) { + const updates = [...this.features.values()].filter(feature => feature.updatedAtMs > request.lastRequestTimeMs); + return updates.length ? new messages.FeatureUpdatedEvent(updates) : null; + } + + async handleFeatureListRequest() { + return [...this.features.values()]; + } + + async handleFeatureUpdateRequest(request: messages.FeatureUpdateRequest) { + let values; + try { + values = validateFeatureValues(request.id, request.values); + } catch (err: any) { + throw new lib.RequestError(err.message); + } + + const feature = new messages.FeatureRecord(request.id, request.enabled, values); + this.features.set(feature); + return feature; } /** diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts new file mode 100644 index 00000000..78db8814 --- /dev/null +++ b/exp_scenario/features.ts @@ -0,0 +1,222 @@ +/** + * The scenario features which can be configured from the web interface. + * + * Defaults must match module/config/.lua, test/features.test.js checks + * this. Only list settings which the lua side reads at runtime. + */ + +export type FeatureValue = boolean | number | string; + +export type FeatureField = { + name: string, + title: string, + description: string, +} & ( + | { type: "boolean", default: boolean } + | { type: "number", default: number, min?: number, unit?: string } + | { type: "string", default: string } +); + +export type Feature = { + name: string, + title: string, + description: string, + fields: FeatureField[], +}; + +export const features: Feature[] = [ + { + name: "afk_kick", + title: "AFK Kick", + description: "Kick everyone when nobody online is active", + fields: [ + { + name: "admin_as_active", + title: "Admins count as active", + description: "Admins are active regardless of how long they have been afk", + type: "boolean", + default: true, + }, + { + name: "trust_as_active", + title: "Trusted players count as active", + description: "Players with enough playtime are active regardless of how long they have been afk", + type: "boolean", + default: true, + }, + { + name: "afk_minutes", + title: "AFK after", + description: "Time without input before a player counts as afk", + type: "number", + default: 10, + min: 1, + unit: "minutes", + }, + { + name: "kick_minutes", + title: "Kick after", + description: "Time without any active player before everyone is kicked", + type: "number", + default: 30, + min: 1, + unit: "minutes", + }, + { + name: "trust_minutes", + title: "Trusted after", + description: "Playtime needed before a player counts as trusted", + type: "number", + default: 600, + min: 0, + unit: "minutes", + }, + ], + }, + { + name: "chat_auto_reply", + title: "Chat Auto Reply", + description: "Reply to key words in chat and run chat commands", + fields: [ + { + name: "command_prefix", + title: "Command prefix", + description: "Prefix for chat commands, and for key words to reply to everyone", + type: "string", + default: "!", + }, + { + name: "command_admin_only", + title: "Commands for admins only", + description: "Only admins can use chat commands", + type: "boolean", + default: false, + }, + ], + }, + { + name: "death_markers", + title: "Death Markers", + description: "What happens when players die", + fields: [ + { + name: "collect_corpses", + title: "Collect corpses", + description: "Put the items of expired corpses in chests", + type: "boolean", + default: true, + }, + { + name: "show_map_markers", + title: "Show map markers", + description: "Add a map marker where a player died", + type: "boolean", + default: true, + }, + { + name: "clean_map_markers", + title: "Remove map markers", + description: "Remove the map marker once the corpse is gone", + type: "boolean", + default: false, + }, + { + name: "include_time_of_death", + title: "Include time of death", + description: "Add the time of death to the map marker", + type: "boolean", + default: true, + }, + { + name: "show_light_at_corpse", + title: "Light at corpse", + description: "Draw a light in the player's colour at their corpse", + type: "boolean", + default: true, + }, + { + name: "show_line_to_corpse", + title: "Line to corpse", + description: "Draw a line from a respawned player to their corpse", + type: "boolean", + default: true, + }, + ], + }, + { + name: "mine_depletion", + title: "Mine Depletion", + description: "Deconstruct mining drills when their resources run out", + fields: [ + { + name: "chest", + title: "Remove output chests", + description: "Also deconstruct the chest a drill outputs into", + type: "boolean", + default: true, + }, + { + name: "beacon", + title: "Remove beacons", + description: "Also deconstruct beacons which no longer affect anything", + type: "boolean", + default: true, + }, + { + name: "fluid", + title: "Replace with pipes", + description: "Place pipe ghosts where a drill carried fluid", + type: "boolean", + default: true, + }, + ], + }, + { + name: "station_auto_name", + title: "Station Auto Name", + description: "Name train stops when they are built", + fields: [ + { + name: "station_name", + title: "Station name", + description: "Placeholders: __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__", + type: "string", + default: "[L] __icon__", + }, + ], + }, +]; + +/** + * Check the values for a feature against its fields. + * + * @returns the values which differ from their default + * @throws Error if the feature is unknown, or a value is unknown or the wrong type + */ +export function validateFeatureValues(name: string, values: Record) { + const feature = features.find(f => f.name === name); + if (!feature) { + throw new Error(`Unknown feature ${name}`); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(values)) { + const field = feature.fields.find(f => f.name === key); + if (!field) { + throw new Error(`Feature ${name} has no setting ${key}`); + } + if (typeof value !== field.type) { + throw new Error(`Setting ${key} of ${name} must be a ${field.type}`); + } + if (field.type === "number" && !Number.isFinite(value)) { + throw new Error(`Setting ${key} of ${name} must be a finite number`); + } + if (field.type === "number" && field.min !== undefined && (value as number) < field.min) { + throw new Error(`Setting ${key} of ${name} must be at least ${field.min}`); + } + if (value !== field.default) { + result[key] = value; + } + } + return result; +} diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 203c5721..5e4a721a 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -20,6 +20,9 @@ export const plugin: lib.PluginDeclaration = { messages: [ messages.SeedRequest, + messages.FeatureUpdatedEvent, + messages.FeatureListRequest, + messages.FeatureUpdateRequest, ], permissions: [ @@ -42,4 +45,7 @@ export const plugin: lib.PluginDeclaration = { ], webEntrypoint: "./web", + routes: [ + "/scenario_features", + ], }; diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index 95a41ff8..c35f7f1a 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -1,3 +1,77 @@ -// Empty because an instance entrypoint must be defined for a module to be injected -// This requirement may change in the future to allow for standalone modules -export default async function () {} +import type { Instance, InstancePluginContext } from "@clusterio/host"; +import * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; + +/** Sends the feature config from the controller to the lua side, see module/features.lua. */ +export class InstancePlugin { + instance: Instance; + logger: lib.Logger; + name: string; + /** Rcon is up and the lua side can take updates */ + started = false; + + constructor(context: InstancePluginContext) { + this.instance = context.instance; + this.logger = context.logger; + this.name = context.plugin.name; + } + + async init() { + this.instance.handle(messages.FeatureUpdatedEvent, this.handleFeatureUpdatedEvent.bind(this)); + this.instance.hooks.start.attach(this.name, this.onStart.bind(this)); + this.instance.hooks.exit.attach(this.name, this.onExit.bind(this)); + this.instance.hooks.controllerConnectionEvent.attach(this.name, this.onControllerConnectionEvent.bind(this)); + } + + async onStart() { + this.started = true; + await this.syncFeatures(); + } + + async onExit() { + this.started = false; + } + + /** A new session means the controller restarted and forgot the subscription. */ + async onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { + if (event === "connect" && this.started) { + await this.syncFeatures(); + } + } + + /** Subscribe to updates and send every feature, updates arriving while listing are covered by the list. */ + async syncFeatures() { + await this.instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_scenario:${messages.FeatureUpdatedEvent.name}`, "subscribe", Date.now(), + )); + const features = await this.instance.sendTo("controller", new messages.FeatureListRequest()); + await this.luaSendFeatures(features); + } + + async handleFeatureUpdatedEvent(event: messages.FeatureUpdatedEvent) { + if (this.started) { + await this.luaSendFeatures(event.updates.filter(feature => !feature.isDeleted)); + } + } + + async luaSendFeatures(features: messages.FeatureRecord[]) { + if (!features.length) { + return; + } + + const updates = features.map(feature => ({ name: feature.id, enabled: feature.enabled, values: feature.values })); + const json = JSON.stringify(updates); + // A long bracket level which the json does not close, string values are user input + let level = "="; + while (json.includes(`]${level}]`)) { + level += "="; + } + await this.instance.sendRcon( + `/sc exp_scenario.features.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true, + ); + } +} + +export default async function (context: InstancePluginContext) { + await new InstancePlugin(context).init(); +} diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index 9c778456..17fdfc62 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -1,3 +1,6 @@ +import * as lib from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; + /** Create the roles and permission groups the scenario shipped with, see seed.ts. */ export class SeedRequest { declare ["constructor"]: typeof SeedRequest; @@ -9,3 +12,121 @@ export class SeedRequest { constructor() {} } + +const FeatureValueSchema = Type.Union([Type.Boolean(), Type.Number(), Type.String()]); + +/** + * The config of one scenario feature, see features.ts. + * + * Only values which differ from the default are stored, so a changed default + * applies to every cluster which did not set that value. + */ +export class FeatureRecord { + constructor( + /** Name of the feature in features.ts */ + public id: string, + public enabled: boolean, + public values: Record = {}, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + enabled: Type.Boolean(), + values: Type.Record(Type.String(), FeatureValueSchema), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + enabled: this.enabled, + values: this.values, + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.enabled, + json.values, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +export class FeatureUpdatedEvent { + declare ["constructor"]: typeof FeatureUpdatedEvent; + static plugin = "exp_scenario" as const; + static type = "event" as const; + static src = "controller" as const; + static dst = ["control", "instance"] as const; + static permission = "exp_scenario.config.view" as const; + + constructor( + public updates: FeatureRecord[], + ) {} + + static jsonSchema = Type.Object({ + updates: Type.Array(FeatureRecord.jsonSchema), + }); + + toJSON() { + return { updates: this.updates.map(feature => feature.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.updates.map(feature => FeatureRecord.fromJSON(feature))); + } +} + +/** List the config of every feature. */ +export class FeatureListRequest { + declare ["constructor"]: typeof FeatureListRequest; + static plugin = "exp_scenario" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_scenario.config.view" as const; + static Response = lib.jsonArray(FeatureRecord); +} + +/** Enable or disable a feature and replace its values, values left out go back to their default. */ +export class FeatureUpdateRequest { + declare ["constructor"]: typeof FeatureUpdateRequest; + static plugin = "exp_scenario" as const; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "exp_scenario.config.edit" as const; + static Response = FeatureRecord; + + constructor( + public id: string, + public enabled: boolean, + public values: Record, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + enabled: Type.Boolean(), + values: Type.Record(Type.String(), FeatureValueSchema), + }); + + static fromJSON(json: Static) { + return new this(json.id, json.enabled, json.values); + } +} diff --git a/exp_scenario/module/config/afk_kick.lua b/exp_scenario/module/config/afk_kick.lua new file mode 100644 index 00000000..8b3ecbd0 --- /dev/null +++ b/exp_scenario/module/config/afk_kick.lua @@ -0,0 +1,18 @@ +--- Settings for kicking players when everyone online is afk +-- @config AFK-Kick + +local Features = require("modules/exp_scenario/features") +local Roles = require("modules/exp_roles") + +return Features.register("afk_kick", { + admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time + trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time + afk_minutes = 10, --- @setting afk_minutes The time in minutes that must pass for a player to be considered afk + kick_minutes = 30, --- @setting kick_minutes The time in minutes that must pass without any active players for all players to be kicked + trust_minutes = 600, --- @setting trust_minutes The time in minutes that a player must be online for to count as trusted + update_time = 3600 * 30, --- @setting update_time How often in ticks the script checks for active players, needs a restart to change + custom_active_check = function(player) + local veteran = Roles.get_role_by_name("Veteran") + return veteran ~= nil and not Roles.get_player_highest_role(player):is_lower_than(veteran) + end, +}) diff --git a/exp_legacy/module/config/chat_reply.lua b/exp_scenario/module/config/chat_auto_reply.lua similarity index 98% rename from exp_legacy/module/config/chat_reply.lua rename to exp_scenario/module/config/chat_auto_reply.lua index 7b624fcf..65916f9d 100644 --- a/exp_legacy/module/config/chat_reply.lua +++ b/exp_scenario/module/config/chat_auto_reply.lua @@ -1,7 +1,8 @@ --- This file defines the different triggers for the chat bot --- @config Chat-Reply +-- @config Chat-Auto-Reply local ExpUtil = require("modules/exp_util") +local Features = require("modules/exp_scenario/features") local Async = require("modules/exp_util/async") local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail @@ -24,7 +25,7 @@ local afk_time_units = { seconds = true, } -return { +return Features.register("chat_auto_reply", { --- @type table messages = { --- @setting messages will trigger when ever the word is said ["discord"] = { "info.discord", config_server_detail["discord"] }, @@ -134,4 +135,4 @@ return { return { locale_reply, { "exp_chat-auto-reply.reply-beer-1" } } end, }, -} +}) diff --git a/exp_legacy/module/config/death_logger.lua b/exp_scenario/module/config/death_markers.lua similarity index 64% rename from exp_legacy/module/config/death_logger.lua rename to exp_scenario/module/config/death_markers.lua index ca90677a..58f6ee3c 100644 --- a/exp_legacy/module/config/death_logger.lua +++ b/exp_scenario/module/config/death_markers.lua @@ -1,15 +1,15 @@ ---- This config controls what happens when a player dies mostly about map markers and item collection; --- allow_teleport_to_body_command and allow_collect_bodies_command can be over ridden if command_auth_runtime_disable is present; --- if not present then the commands will not be loaded into the game --- @config Death-Logger +--- This config controls what happens when a player dies mostly about map markers and item collection +-- @config Death-Markers -return { +local Features = require("modules/exp_scenario/features") + +return Features.register("death_markers", { collect_corpses = true, --- @setting collect_corpses enables items being returned to the spawn point in chests upon corpse expiring show_map_markers = true, --- @setting show_map_markers shows markers on the map where bodies are - clean_map_markers = false, + clean_map_markers = false, --- @setting clean_map_markers removes the map marker once the body is gone include_time_of_death = true, --- @setting include_time_of_death weather to include the time of death on the map marker map_icon = nil, --- @setting map_icon the icon that the map marker shows; nil means no icon; format as a SingleID show_light_at_corpse = true, --- @setting show_light_at_corpse if a light should be rendered at the corpse show_line_to_corpse = true, --- @setting show_line_to_corpse if a line should be rendered from you to your corpse - period_check_map_tags = 60 * 60 * 5, -} + period_check_map_tags = 60 * 60 * 5, --- @setting period_check_map_tags ticks between checks for missing map markers, needs a restart to change +}) diff --git a/exp_legacy/module/config/miner.lua b/exp_scenario/module/config/mine_depletion.lua similarity index 62% rename from exp_legacy/module/config/miner.lua rename to exp_scenario/module/config/mine_depletion.lua index f6fcf84c..e0d2c086 100644 --- a/exp_legacy/module/config/miner.lua +++ b/exp_scenario/module/config/mine_depletion.lua @@ -1,8 +1,10 @@ ---- Settings for miner --- @config miner +--- Settings for mine depletion +-- @config Mine-Depletion -return { +local Features = require("modules/exp_scenario/features") + +return Features.register("mine_depletion", { fluid = true, --- @setting fluid When true, checks for for fluid pipes when removing miners chest = true, --- @setting chest When true, checks for for chest when removing miners beacon = true, --- @setting beacon When true, checks for for beacon when removing miners -} +}) diff --git a/exp_scenario/module/config/station_auto_name.lua b/exp_scenario/module/config/station_auto_name.lua new file mode 100644 index 00000000..8de9e4e2 --- /dev/null +++ b/exp_scenario/module/config/station_auto_name.lua @@ -0,0 +1,16 @@ +--- Settings for naming train stations when they are built +-- @config Station-Auto-Name + +local Features = require("modules/exp_scenario/features") + +return Features.register("station_auto_name", { + --[[ + __icon__ + __item_name__ + __backer_name__ + __direction__ + __x__ + __y__ + ]] + station_name = "[L] __icon__", --- @setting station_name the name given to new stations, uses the placeholders above +}) diff --git a/exp_scenario/module/control/afk_kick.lua b/exp_scenario/module/control/afk_kick.lua index 24f9b378..b44fad0c 100644 --- a/exp_scenario/module/control/afk_kick.lua +++ b/exp_scenario/module/control/afk_kick.lua @@ -3,8 +3,11 @@ Kicks players when all players on the server are afk ]] local Async = require("modules/exp_util/async") +local Features = require("modules/exp_scenario/features") local Storage = require("modules/exp_util/storage") -local config = require("modules.exp_legacy.config.afk_kick") +local config = require("modules/exp_scenario/config/afk_kick") + +local ticks_per_minute = 3600 --- @type { last_active: number } local script_data = { last_active = 0 } @@ -15,16 +18,16 @@ end) --- Kicks an afk player, used to add a delay so the gui has time to appear local afk_kick_player_async = Async.register(function(player) - if game.tick - script_data.last_active < config.kick_time then return end + if game.tick - script_data.last_active < config.kick_minutes * ticks_per_minute then return end game.kick_player(player, "AFK while no active players on the server") end) --- Check if there is an active player local function has_active_player() for _, player in ipairs(game.connected_players) do - if player.afk_time < config.afk_time + if player.afk_time < config.afk_minutes * ticks_per_minute or config.admin_as_active and player.admin - or config.trust_as_active and player.online_time > config.trust_time + or config.trust_as_active and player.online_time > config.trust_minutes * ticks_per_minute or config.custom_active_check and config.custom_active_check(player) then script_data.last_active = game.tick return true @@ -40,7 +43,7 @@ local function check_afk_players() if has_active_player() then return end -- Check if players should be kicked - if game.tick - script_data.last_active < config.kick_time then return end + if game.tick - script_data.last_active < config.kick_minutes * ticks_per_minute then return end -- Kick time exceeded, kick all players for _, player in ipairs(game.connected_players) do @@ -73,7 +76,7 @@ end local e = defines.events -return { +return Features.guard(config, { events = { [e.on_player_joined_game] = on_player_joined_game, }, @@ -81,4 +84,4 @@ return { [config.update_time] = check_afk_players, }, has_active_player = has_active_player, -} +}) diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index 4e60ca40..e3d23092 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -2,10 +2,9 @@ Adds auto replies to chat messages, as well as chat commands ]] +local Features = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.chat_reply") -local prefix = config.command_prefix -local prefix_len = string.len(prefix) +local config = require("modules/exp_scenario/config/chat_auto_reply") local find = string.find local sub = string.sub @@ -16,6 +15,8 @@ local function on_console_chat(event) if not event.player_index then return end local player = assert(game.get_player(event.player_index)) local message = event.message:lower():gsub("%s+", "") + local prefix = config.command_prefix + local prefix_len = string.len(prefix) -- Check if the player can chat commands local commands_allowed = true @@ -56,8 +57,8 @@ end local e = defines.events -return { +return Features.guard(config, { events = { [e.on_console_chat] = on_console_chat, - } -} + }, +}) diff --git a/exp_scenario/module/control/death_markers.lua b/exp_scenario/module/control/death_markers.lua index 24a74f30..5c586fae 100644 --- a/exp_scenario/module/control/death_markers.lua +++ b/exp_scenario/module/control/death_markers.lua @@ -3,8 +3,9 @@ Makes markers on the map where places have died and reclaims items if not recove ]] local ExpUtil = require("modules/exp_util") +local Features = require("modules/exp_scenario/features") local Storage = require("modules/exp_util/storage") -local config = require("modules.exp_legacy.config.death_logger") +local config = require("modules/exp_scenario/config/death_markers") local map_tag_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true } @@ -40,6 +41,7 @@ end --- Checks that all map tags are present and valid, creating any that are missing local function check_map_tags() + if not config.show_map_markers then return end for _, corpse_data in pairs(character_corpses) do if not corpse_data.tag or not corpse_data.tag.valid then create_map_tag(corpse_data) @@ -98,6 +100,7 @@ end --- Draw lines to the player corpse --- @param event EventData.on_player_respawned local function on_player_respawned(event) + if not config.show_line_to_corpse then return end local index = event.player_index local player = assert(game.get_player(index)) for _, corpse_data in pairs(character_corpses) do @@ -122,6 +125,7 @@ end --- Collect all items from expired character corpses --- @param event EventData.on_character_corpse_expired local function on_character_corpse_expired(event) + if not config.collect_corpses then return end local corpse = event.corpse local inventory = assert(corpse.get_inventory(defines.inventory.character_corpse)) ExpUtil.transfer_inventory_to_surface{ @@ -132,26 +136,16 @@ local function on_character_corpse_expired(event) } end -local on_nth_tick = {} -if config.show_map_markers then - on_nth_tick[config.period_check_map_tags] = check_map_tags -end - local e = defines.events -local events = { - [e.on_player_died] = on_player_died, - [e.on_object_destroyed] = on_object_destroyed, -} -if config.show_line_to_corpse then - events[e.on_player_respawned] = on_player_respawned -end - -if config.collect_corpses then - events[e.on_character_corpse_expired] = on_character_corpse_expired -end - -return { - on_nth_tick = on_nth_tick, - events = events, -} +return Features.guard(config, { + on_nth_tick = { + [config.period_check_map_tags] = check_map_tags, + }, + events = { + [e.on_player_died] = on_player_died, + [e.on_object_destroyed] = on_object_destroyed, + [e.on_player_respawned] = on_player_respawned, + [e.on_character_corpse_expired] = on_character_corpse_expired, + }, +}) diff --git a/exp_scenario/module/control/mine_depletion.lua b/exp_scenario/module/control/mine_depletion.lua index f8a6cb55..45701b8f 100644 --- a/exp_scenario/module/control/mine_depletion.lua +++ b/exp_scenario/module/control/mine_depletion.lua @@ -3,7 +3,8 @@ Marks mining drills for deconstruction when resources deplete ]] local Async = require("modules/exp_util/async") -local config = require("modules.exp_legacy.config.miner") +local Features = require("modules/exp_scenario/features") +local config = require("modules/exp_scenario/config/mine_depletion") local floor = math.floor @@ -270,8 +271,8 @@ end local e = defines.events -return { +return Features.guard(config, { events = { [e.on_resource_depleted] = on_resource_depleted, }, -} +}) diff --git a/exp_scenario/module/control/station_auto_name.lua b/exp_scenario/module/control/station_auto_name.lua index 96b8fc09..0b135321 100644 --- a/exp_scenario/module/control/station_auto_name.lua +++ b/exp_scenario/module/control/station_auto_name.lua @@ -2,7 +2,8 @@ Automatically name stations when they are placed based on closest resource and direction from spawn ]] -local config = require("modules.exp_legacy.config.station_auto_name") +local Features = require("modules/exp_scenario/features") +local config = require("modules/exp_scenario/config/station_auto_name") local get_direction do local directions = { @@ -98,9 +99,9 @@ end local e = defines.events -return { +return Features.guard(config, { events = { [e.on_built_entity] = rename_station, [e.on_robot_built_entity] = rename_station, - } -} + }, +}) diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua new file mode 100644 index 00000000..38b5c5df --- /dev/null +++ b/exp_scenario/module/features.lua @@ -0,0 +1,135 @@ +--[[-- ExpScenario - Features +Feature config which the controller can change while the game is running + +Each feature declares its defaults in config/.lua by returning the table +from Features.register. The controller sends overrides over rcon, these are kept +in storage and copied onto that same table, so read values when they are used +rather than copying them into locals when the file loads. + +Only boolean, number, and string defaults can be overridden. Values used while +the file loads, such as on_nth_tick periods, can not change without a restart +and should not be listed in features.ts. + +--- Declaring a feature: +local Features = require("modules/exp_scenario/features") +return Features.register("my_feature", { + show_message = true, +}) + +--- Using it from a control file, handlers do nothing while it is disabled: +local config = require("modules/exp_scenario/config/my_feature") +return Features.guard(config, { + events = { [defines.events.on_player_joined_game] = on_player_joined_game }, +}) +]] + +local Storage = require("modules/exp_util/storage") + +--- @class ExpScenario.Features +local Features = {} + +local overridable = { + boolean = true, + number = true, + string = true, +} + +--- @class ExpScenario.FeatureConfig +--- @field enabled boolean + +--- The live config tables by feature name +local features = {} --- @type table + +--- Copies of the defaults by feature name, used to undo overrides +local defaults = {} --- @type table> + +--- Overrides received from the controller by feature name +local overrides = {} --- @type table> + +--- Reset a feature to its defaults and apply its overrides +--- @param name string +local function apply(name) + local config = features[name] + local feature_defaults = defaults[name] + for key, value in pairs(feature_defaults) do + config[key] = value + end + + for key, value in pairs(overrides[name] or {}) do + local default = feature_defaults[key] + if default ~= nil and type(value) == type(default) and overridable[type(value)] then + config[key] = value + end + end +end + +Storage.register(overrides, function(tbl) + overrides = tbl + for name in pairs(features) do + apply(name) + end +end) + +--- Register a feature and its defaults, features are enabled unless the defaults say otherwise +--- @generic T : table +--- @param name string +--- @param config T +--- @return T +function Features.register(name, config) + assert(features[name] == nil, "Feature already registered: " .. name) + if config.enabled == nil then + config.enabled = true + end + + local feature_defaults = {} + for key, value in pairs(config) do + feature_defaults[key] = value + end + + features[name] = config + defaults[name] = feature_defaults + return config +end + +--- Wrap the event and on_nth_tick handlers of an event handler lib so they do nothing while the feature is disabled +--- @generic T : table +--- @param config ExpScenario.FeatureConfig +--- @param lib T +--- @return T +function Features.guard(config, lib) + for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do + for key, handler in pairs(handlers) do + handlers[key] = function(event) + if config.enabled then + return handler(event) + end + end + end + end + + return lib +end + +--- @class ExpScenario.FeatureUpdate +--- @field name string +--- @field enabled boolean +--- @field values table + +--- Called over rcon by the instance plugin with the features which changed, unknown features are ignored +--- @param updates ExpScenario.FeatureUpdate[] +function Features.receive_update(updates) + for _, update in ipairs(updates) do + local name = update.name + if features[name] then + local override = { enabled = update.enabled } + for key, value in pairs(update.values) do + override[key] = value + end + + overrides[name] = override + apply(name) + end + end +end + +return Features diff --git a/exp_scenario/module/globals.lua b/exp_scenario/module/globals.lua new file mode 100644 index 00000000..bdb9ef6a --- /dev/null +++ b/exp_scenario/module/globals.lua @@ -0,0 +1,6 @@ +--- @diagnostic disable: global-in-non-module + +-- Access using `/sc exp_scenario.features.foo()` +exp_scenario = { + features = require("modules/exp_scenario/features"), +} diff --git a/exp_scenario/module/module.json b/exp_scenario/module/module.json index 34082135..2b2d40e9 100644 --- a/exp_scenario/module/module.json +++ b/exp_scenario/module/module.json @@ -3,6 +3,7 @@ "load": [ ], "require": [ + "globals.lua", "control.lua" ], "dependencies": { diff --git a/exp_scenario/package.json b/exp_scenario/package.json index 706fef28..191b13be 100644 --- a/exp_scenario/package.json +++ b/exp_scenario/package.json @@ -17,15 +17,18 @@ }, "peerDependencies": { "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^" }, "devDependencies": { "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", "@types/node": "catalog:", "@types/react": "catalog:", "antd": "catalog:", + "fengari": "^0.1.5", "react": "catalog:", "react-dom": "catalog:", "tap": "^21.1.0", diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index befc28df..e856115c 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -4,6 +4,7 @@ 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 { features } from "../dist/node/features.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"; @@ -16,7 +17,7 @@ import { plugin } from "../dist/node/index.js"; lib.registerPluginPermissions([plugin]); // The controller validates message classes against the link registry -for (const Message of [messages.SeedRequest, ...roles.plugin.messages, ...groups.plugin.messages]) { +for (const Message of [...plugin.messages, ...roles.plugin.messages, ...groups.plugin.messages]) { lib.Link.register(Message); } @@ -29,9 +30,9 @@ t.after(() => { }); /** Build the plugin around a real controller, which is side effect free while not started. */ -async function startPlugin(t2, { withPlugins = true } = {}) { +async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.testdir() } = {}) { const controllerConfig = new lib.ControllerConfig("controller", { - "controller.database_directory": t2.testdir(), + "controller.database_directory": databaseDirectory, "controller.default_role_id": lib.Role.DefaultPlayerRoleId, }); const controller = new Controller(logger, [], controllerConfig); @@ -135,5 +136,47 @@ t.test("class ControllerPlugin", t2 => { t3.notOk([...first.values()].filter(priority => priority === 2).length > 1, "seed priorities skip taken ones"); }); + t2.test(".init() adds every feature with its defaults", async t3 => { + const { plugin } = await startPlugin(t3, { withPlugins: false }); + const records = await plugin.handleFeatureListRequest(); + t3.strictSame(records.map(record => record.id), features.map(feature => feature.name), "one record per feature"); + t3.ok(records.every(record => record.enabled && !Object.keys(record.values).length), "enabled with no overrides"); + }); + + t2.test(".handleFeatureUpdateRequest() stores the values which differ from the default", async t3 => { + const { plugin } = await startPlugin(t3, { withPlugins: false }); + const broadcasts = []; + plugin.controller.subscriptions.broadcast = event => broadcasts.push(event); + + const record = await plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest( + "death_markers", false, { show_map_markers: false, collect_corpses: true }, + )); + t3.strictSame(record.values, { show_map_markers: false }, "the default value is dropped"); + t3.equal(plugin.features.get("death_markers").enabled, false, "the feature is disabled"); + t3.strictSame(broadcasts.map(event => event.updates.map(update => update.id)), [["death_markers"]], "the update is broadcast"); + + const subscription = await plugin.handleFeatureSubscription({ lastRequestTimeMs: record.updatedAtMs - 1 }); + t3.strictSame(subscription.updates.map(update => update.id), ["death_markers"], "subscribers catch up on the change"); + }); + + t2.test(".handleFeatureUpdateRequest() rejects invalid values", async t3 => { + const { plugin } = await startPlugin(t3, { withPlugins: false }); + await t3.rejects( + plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest("death_markers", true, { unknown: 1 })), + { message: "Feature death_markers has no setting unknown" }, + ); + }); + + t2.test(".init() keeps stored features across restarts", async t3 => { + const databaseDirectory = t3.testdir(); + const { plugin } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + await plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 })); + await plugin.onShutdown(); + + const { plugin: restarted } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + const record = restarted.features.get("afk_kick"); + t3.strictSame([record.enabled, record.values], [false, { afk_minutes: 5 }], "the stored record is loaded"); + }); + t2.end(); }); diff --git a/exp_scenario/test/features.test.js b/exp_scenario/test/features.test.js new file mode 100644 index 00000000..e5139e13 --- /dev/null +++ b/exp_scenario/test/features.test.js @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import path from "node:path"; +import t from "tap"; +import { features, validateFeatureValues } from "../dist/node/features.js"; + +/** Read a literal default from a module/config file, only covers single line booleans, numbers, and strings. */ +function luaDefault(source, key) { + const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"),`, "m").exec(source); + return match ? JSON.parse(match[1]) : undefined; +} + +t.test("features", t2 => { + for (const feature of features) { + t2.test(feature.name, t3 => { + const file = path.join(import.meta.dirname, "..", "module", "config", `${feature.name}.lua`); + const source = fs.readFileSync(file, "utf8"); + t3.match(source, `Features.register("${feature.name}"`, "the config file registers the feature"); + for (const field of feature.fields) { + t3.equal(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`); + } + t3.end(); + }); + } + t2.end(); +}); + +t.test("validateFeatureValues()", t2 => { + t2.same( + validateFeatureValues("death_markers", { show_map_markers: false, collect_corpses: true }), + { show_map_markers: false }, + "values equal to their default are dropped", + ); + t2.throws(() => validateFeatureValues("unknown", {}), { message: "Unknown feature unknown" }); + t2.throws( + () => validateFeatureValues("death_markers", { period_check_map_tags: 60 }), + { message: "Feature death_markers has no setting period_check_map_tags" }, + ); + t2.throws( + () => validateFeatureValues("death_markers", { show_map_markers: "no" }), + { message: "Setting show_map_markers of death_markers must be a boolean" }, + ); + t2.throws( + () => validateFeatureValues("afk_kick", { afk_minutes: 0 }), + { message: "Setting afk_minutes of afk_kick must be at least 1" }, + ); + t2.throws( + () => validateFeatureValues("afk_kick", { afk_minutes: Infinity }), + { message: "Setting afk_minutes of afk_kick must be a finite number" }, + ); + t2.end(); +}); diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js new file mode 100644 index 00000000..c1bad245 --- /dev/null +++ b/exp_scenario/test/instance.test.js @@ -0,0 +1,120 @@ +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 +for (const Message of pluginDeclaration.messages) { + lib.Link.register(Message); +} + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +class TestConnector extends lib.BaseConnector { + constructor() { + super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 })); + this.valid = true; + this.connected = true; + this.hasSession = true; + } + + send() {} +} + +/** Build a plugin around a real instance with spies on what leaves it. */ +async function startPlugin(t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })]) { + const instanceConfig = new lib.InstanceConfig("host"); + instanceConfig.set("instance.id", 1); + instanceConfig.set("instance.name", "test"); + + const instance = new Instance( + { assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig + ); + + const state = { sent: [], rcons: [] }; + instance.server = { + handle: () => {}, + sendRcon: async command => { + state.rcons.push(command); + return ""; + }, + }; + instance.sendTo = async (dst, request) => { + state.sent.push(request); + return request instanceof messages.FeatureListRequest ? features : undefined; + }; + + const plugin = new InstancePlugin({ plugin: { name: "exp_scenario" }, instance, host: {}, logger }); + await plugin.init(); + return { plugin, instance, state }; +} + +/** The payload of a recorded rcon command. */ +function decodeRcon(command) { + const match = command.match(/^\/sc exp_scenario\.features\.receive_update\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s); + return JSON.parse(match[2]); +} + +t.test("class InstancePlugin", t2 => { + t2.test(".onStart() subscribes and sends every feature to lua", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.onStart(); + + t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first"); + t3.ok(state.sent[1] instanceof messages.FeatureListRequest, "then lists the features"); + t3.strictSame( + decodeRcon(state.rcons[0]), + [{ name: "death_markers", enabled: false, values: { show_map_markers: false } }], + ); + }); + + t2.test(".handleFeatureUpdatedEvent() only sends while the game is up", async t3 => { + const { plugin, state } = await startPlugin(t3); + const event = new messages.FeatureUpdatedEvent([ + new messages.FeatureRecord("afk_kick", true, { afk_minutes: 5 }), + new messages.FeatureRecord("removed", true, {}, 0, true), + ]); + + await plugin.handleFeatureUpdatedEvent(event); + t3.strictSame(state.rcons, [], "nothing is sent before start"); + + await plugin.onStart(); + state.rcons.length = 0; + await plugin.handleFeatureUpdatedEvent(event); + t3.strictSame(decodeRcon(state.rcons[0]), [{ name: "afk_kick", enabled: true, values: { afk_minutes: 5 } }], + "deleted features are left out"); + + await plugin.onExit(); + state.rcons.length = 0; + await plugin.handleFeatureUpdatedEvent(event); + t3.strictSame(state.rcons, [], "nothing is sent after exit"); + }); + + t2.test(".onControllerConnectionEvent() syncs again after the controller restarts", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.onControllerConnectionEvent("connect"); + t3.strictSame(state.sent, [], "nothing happens before start"); + + await plugin.onStart(); + state.sent.length = 0; + state.rcons.length = 0; + await plugin.onControllerConnectionEvent("resume"); + t3.strictSame(state.sent, [], "a resumed session keeps its subscription"); + + await plugin.onControllerConnectionEvent("connect"); + t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again"); + t3.equal(state.rcons.length, 1, "and sends every feature"); + }); + + t2.test(".luaSendFeatures() picks a long bracket the values do not close", async t3 => { + const { plugin, state } = await startPlugin(t3); + const name = "a]=]b]==]c"; + await plugin.luaSendFeatures([new messages.FeatureRecord("station_auto_name", true, { station_name: name })]); + t3.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json"); + t3.strictSame(decodeRcon(state.rcons[0])[0].values.station_name, name); + }); + + t2.end(); +}); diff --git a/exp_scenario/test/module.test.js b/exp_scenario/test/module.test.js new file mode 100644 index 00000000..0aa3fde7 --- /dev/null +++ b/exp_scenario/test/module.test.js @@ -0,0 +1,8 @@ +import path from "node:path"; +import t from "tap"; +import { reportLuaTests } from "../../test/lua/runner.js"; + +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("features.lua", t2 => reportLuaTests(t2, envFile, path.join(import.meta.dirname, "module", "features.lua"))); diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua new file mode 100644 index 00000000..b46a4e34 --- /dev/null +++ b/exp_scenario/test/module/env.lua @@ -0,0 +1,46 @@ +--[[-- Test environment for module/features.lua +Extends the shared stubs with a fresh copy of the features module and a stub +of exp_util storage which lets tests act out on_init and on_load. + +Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite +returned here becomes `...` in each test file. +]] + +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source:gsub("\\", "/") +local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$")) + +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework + +--- @class ExpScenario.TestEnv : Stubs +--- @field Features ExpScenario.Features A fresh copy of the features module +--- @field storage table The table registered with storage, as on_init would store it +--- @field on_load fun(tbl: table) Act out on_load with the given storage table +--- @field load_config fun(name: string): table Load module/config/.lua + +return Framework.suite(function(env) + --- @cast env ExpScenario.TestEnv + local callback --- @type fun(tbl: table) + env.extend_requires{ + ["modules/exp_util/storage"] = { + register = function(tbl, fn) + env.storage = tbl + callback = fn + end, + }, + } + + env.Features = assert(loadfile(plugin_root .. "/module/features.lua"))() + env.extend_requires{ ["modules/exp_scenario/features"] = env.Features } + + function env.on_load(tbl) + env.storage = tbl + callback(tbl) + end + + function env.load_config(name) + return assert(loadfile(plugin_root .. "/module/config/" .. name .. ".lua"))() + end + + return env +end) diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua new file mode 100644 index 00000000..4cb14eba --- /dev/null +++ b/exp_scenario/test/module/features.lua @@ -0,0 +1,63 @@ +local Suite = ... --- @type Suite + +Suite.test("register() enables features unless the defaults say otherwise", function(env) + Suite.eq(env.Features.register("on", { value = 1 }).enabled, true, "enabled by default") + Suite.eq(env.Features.register("off", { enabled = false }).enabled, false, "the defaults can disable it") + Suite.throws(function() env.Features.register("on", {}) end, "Feature already registered: on", "names are unique") +end) + +Suite.test("receive_update() changes the registered table in place", function(env) + local config = env.Features.register("test", { flag = true, count = 5, text = "a" }) + env.Features.receive_update{ { name = "test", enabled = false, values = { flag = false, count = 7 } } } + Suite.eq(config, { enabled = false, flag = false, count = 7, text = "a" }, "given values replace the defaults") + + env.Features.receive_update{ { name = "test", enabled = true, values = { text = "b" } } } + Suite.eq(config, { enabled = true, flag = true, count = 5, text = "b" }, "values left out go back to their default") + Suite.eq(env.storage, { test = { enabled = true, text = "b" } }, "the overrides are kept in storage") +end) + +Suite.test("receive_update() ignores values which can not be overridden", function(env) + local function check() return true end + local config = env.Features.register("test", { flag = true, check = check, icon = nil }) + env.Features.receive_update{ + { name = "unknown", enabled = false, values = {} }, + { name = "test", enabled = true, values = { flag = "yes", check = false, icon = "x", extra = 1 } }, + } + Suite.eq(config, { enabled = true, flag = true, check = check }, "wrong types, functions, and unknown keys are skipped") + Suite.eq(env.storage.unknown, nil, "unknown features are not stored") +end) + +Suite.test("on_load applies the overrides from storage", function(env) + local config = env.Features.register("test", { flag = true }) + env.on_load{ test = { enabled = false, flag = false } } + Suite.eq(config, { enabled = false, flag = false }, "the saved overrides apply") + + env.Features.receive_update{ { name = "test", enabled = true, values = {} } } + Suite.eq(env.storage, { test = { enabled = true } }, "later updates go to the loaded storage table") +end) + +Suite.test("guard() skips handlers while the feature is disabled", function(env) + local config = env.Features.register("test", {}) + local calls = {} + local lib = env.Features.guard(config, { + events = { on_event = function(event) calls[#calls + 1] = event end }, + on_nth_tick = { [60] = function(event) calls[#calls + 1] = event end }, + on_init = function() end, + }) + + lib.events.on_event("a") + lib.on_nth_tick[60]("b") + env.Features.receive_update{ { name = "test", enabled = false, values = {} } } + lib.events.on_event("c") + lib.on_nth_tick[60]("d") + Suite.eq(calls, { "a", "b" }, "only called while enabled") +end) + +Suite.test("config files register their feature", function(env) + local config = env.load_config("death_markers") + env.Features.receive_update{ { name = "death_markers", enabled = true, values = { show_map_markers = false } } } + Suite.eq(config.show_map_markers, false, "death_markers can be changed") + Suite.eq(config.period_check_map_tags, 60 * 60 * 5, "other values keep their default") +end) + +return Suite.run() diff --git a/exp_scenario/tsconfig.browser.json b/exp_scenario/tsconfig.browser.json index b67577c5..c60f963b 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", "index.ts", "messages.ts", "permissions.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "features.ts", "permissions.ts", "package.json" ], } diff --git a/exp_scenario/web/components/FeaturesPage.tsx b/exp_scenario/web/components/FeaturesPage.tsx new file mode 100644 index 00000000..7e134615 --- /dev/null +++ b/exp_scenario/web/components/FeaturesPage.tsx @@ -0,0 +1,147 @@ +import React, { useContext, useState } from "react"; +import { Button, Card, Col, Empty, Form, Input, InputNumber, Row, Space, Switch, Typography, theme } from "antd"; + +import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount } from "@clusterio/web_ui"; + +import { Feature, FeatureField, FeatureValue, features } from "../../features.js"; +import { FeatureRecord, FeatureUpdateRequest } from "../../messages.js"; +import { useFeatures } from "../index"; + +function FeatureListItem(props: { feature: Feature, enabled: boolean, selected: boolean, onClick: () => void }) { + const { token } = theme.useToken(); + return
+ + {props.feature.title} +
; +} + +/** Returned as an element rather than a component so Form.Item can pass its value and onChange to it. */ +function fieldInput(field: FeatureField, disabled: boolean) { + switch (field.type) { + case "boolean": + return ; + case "number": + return ; + case "string": + return ; + } +} + +function defaultValues(feature: Feature) { + return Object.fromEntries(feature.fields.map(field => [field.name, field.default])); +} + +function FeatureForm(props: { feature: Feature, record: FeatureRecord | undefined }) { + const { feature, record } = props; + const control = useContext(ControlContext); + const account = useAccount(); + const [form] = Form.useForm(); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const canEdit = account.hasPermission("exp_scenario.config.edit"); + + const initialValues = { + enabled: record?.enabled ?? true, + ...defaultValues(feature), + ...record?.values, + }; + + function save() { + const { enabled, ...values } = form.getFieldsValue() as { enabled: boolean } & Record; + setSaving(true); + control.send(new FeatureUpdateRequest(feature.name, enabled, values)) + .then(() => setDirty(false)) + .catch(notifyErrorHandler(`Error saving ${feature.title}`)) + .finally(() => setSaving(false)); + } + + return + {feature.title} + + {feature.description} + + } + > +
setDirty(true)} + onFinish={save} + > + + + + {feature.fields.map(field => + {fieldInput(field, !canEdit)} + )} + {canEdit && + + + } +
+
; +} + +/** Enable, disable, and configure the scenario features listed in features.ts. */ +export default function FeaturesPage() { + const [records] = useFeatures(); + const [selected, setSelected] = useState(features[0]?.name); + const feature = features.find(f => f.name === selected); + const record = selected ? records.get(selected) : undefined; + + return + + + + + {features.map(f => setSelected(f.name)} + />)} + + + + {feature + // Remount on save from anywhere so the form shows the stored values + ? + : } + + + ; +} diff --git a/exp_scenario/web/index.tsx b/exp_scenario/web/index.tsx index 60e26971..c9ad368b 100644 --- a/exp_scenario/web/index.tsx +++ b/exp_scenario/web/index.tsx @@ -1,9 +1,32 @@ +import React, { useCallback, useSyncExternalStore } from "react"; import { WebPluginContext } from "@clusterio/web_ui"; +import * as lib from "@clusterio/lib"; +import * as messages from "../messages.js"; + +import FeaturesPage from "./components/FeaturesPage"; import Seed from "./components/Seed"; +let features: lib.MapSubscriber | undefined; + +export function useFeatures() { + const subscribe = useCallback((cb: () => void) => features!.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => features!.getSnapshot()); +} + export default function registerPlugin(context: WebPluginContext) { context.control.hooks.extensionComponents.attach("exp_scenario", () => ({ RolesPage: Seed, })); + + context.control.hooks.pages.attach("exp_scenario", () => [ + { + path: "/scenario_features", + sidebarName: "Scenario Features", + permission: "exp_scenario.config.view", + content: , + }, + ]); + + features = new lib.MapSubscriber(messages.FeatureUpdatedEvent, context.control); } From 69e9f172a312a87093ef897cc812287f41c0517e Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:43:31 +0000 Subject: [PATCH 02/11] Cover guis, commands, and intervals in the features framework Review feedback from Cooldude on the first version. Features.register is renamed to Features.config, and guard also takes a feature name so a feature without settings needs no config call. Autofill uses this. Small configs now live at the top of the file that uses them: death markers, mine depletion, station auto name, and lawnmower. AFK kick and chat auto reply keep separate files because they contain functions. Periods no longer need a restart. A lib can list intervals, which map a config key to a handler that runs every config[key] seconds. They are checked once a second with a stateless tick check. AFK kick and death markers use this and expose their periods on the page. Guis: receive_update raises on_feature_changed, and control/features.lua refreshes every player's gui when it fires. Autofill's toolbar button checks Features.is_enabled, and hiding the button also hides its left element. Quick actions refresh on the event too. Commands: add_flags{ feature = ... } ties a command to a feature, and a new authority denies it while the feature is disabled. The custom help and quick actions already leave out denied commands. Lawnmower uses this and is now added as a lib, so its destroy_decoratives handlers are actually registered. Co-Authored-By: Claude Opus 5.5 --- exp_legacy/module/config/lawnmower.lua | 6 -- exp_scenario/features.ts | 43 +++++++- exp_scenario/module/commands/_authorities.lua | 12 +++ exp_scenario/module/commands/lawnmower.lua | 27 ++--- exp_scenario/module/config/afk_kick.lua | 4 +- .../module/config/chat_auto_reply.lua | 2 +- exp_scenario/module/config/death_markers.lua | 15 --- exp_scenario/module/config/mine_depletion.lua | 10 -- .../module/config/station_auto_name.lua | 16 --- exp_scenario/module/control.lua | 3 +- exp_scenario/module/control/afk_kick.lua | 4 +- exp_scenario/module/control/death_markers.lua | 16 ++- exp_scenario/module/control/features.lua | 19 ++++ .../module/control/mine_depletion.lua | 7 +- .../module/control/station_auto_name.lua | 7 +- exp_scenario/module/features.lua | 101 ++++++++++++++---- exp_scenario/module/gui/autofill.lua | 9 +- exp_scenario/module/gui/quick_actions.lua | 9 ++ exp_scenario/module/locale/en.cfg | 3 + exp_scenario/test/features.test.js | 27 +++-- exp_scenario/test/module/features.lua | 80 +++++++++++--- 21 files changed, 304 insertions(+), 116 deletions(-) delete mode 100644 exp_legacy/module/config/lawnmower.lua delete mode 100644 exp_scenario/module/config/death_markers.lua delete mode 100644 exp_scenario/module/config/mine_depletion.lua delete mode 100644 exp_scenario/module/config/station_auto_name.lua create mode 100644 exp_scenario/module/control/features.lua diff --git a/exp_legacy/module/config/lawnmower.lua b/exp_legacy/module/config/lawnmower.lua deleted file mode 100644 index f31559c8..00000000 --- a/exp_legacy/module/config/lawnmower.lua +++ /dev/null @@ -1,6 +0,0 @@ ---- Settings for lawnmower --- @config lawnmower - -return { - destroy_decoratives = false, -} diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts index 78db8814..c20b6459 100644 --- a/exp_scenario/features.ts +++ b/exp_scenario/features.ts @@ -1,8 +1,9 @@ /** * The scenario features which can be configured from the web interface. * - * Defaults must match module/config/.lua, test/features.test.js checks - * this. Only list settings which the lua side reads at runtime. + * Defaults must match the Features.config call in the lua module, + * test/features.test.js checks this. Only list settings which the lua side + * reads at runtime. */ export type FeatureValue = boolean | number | string; @@ -71,8 +72,23 @@ export const features: Feature[] = [ min: 0, unit: "minutes", }, + { + name: "update_seconds", + title: "Check every", + description: "How often to check for active players", + type: "number", + default: 1800, + min: 1, + unit: "seconds", + }, ], }, + { + name: "autofill", + title: "Autofill", + description: "Fill ammo and fuel into entities when they are built, configured by each player from the toolbar", + fields: [], + }, { name: "chat_auto_reply", title: "Chat Auto Reply", @@ -141,6 +157,29 @@ export const features: Feature[] = [ type: "boolean", default: true, }, + { + name: "check_map_tags_seconds", + title: "Check map markers every", + description: "How often to replace map markers which were removed", + type: "number", + default: 300, + min: 1, + unit: "seconds", + }, + ], + }, + { + name: "lawnmower", + title: "Lawnmower", + description: "The /lawnmower command which clears corpses and nuclear ground", + fields: [ + { + name: "destroy_decoratives", + title: "Clear decoratives when building", + description: "Remove decoratives under entities when they are built", + type: "boolean", + default: false, + }, ], }, { diff --git a/exp_scenario/module/commands/_authorities.lua b/exp_scenario/module/commands/_authorities.lua index 37093ad8..7b7a4f89 100644 --- a/exp_scenario/module/commands/_authorities.lua +++ b/exp_scenario/module/commands/_authorities.lua @@ -5,6 +5,7 @@ Adds a permission authority which checks the clusterio permission for a command local Commands = require("modules/exp_commands") local add, allow, deny = Commands.add_permission_authority, Commands.status.success, Commands.status.unauthorised +local Features = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local player_has_permission = Roles.player_has_permission @@ -22,6 +23,17 @@ authorities.exp_permission = end end) +--- Commands flagged with a feature are denied while it is disabled, see features.lua +authorities.feature_enabled = + add(function(player, command) + local feature = command.flags.feature + if feature and not Features.is_enabled(feature) then + return deny{ "exp-commands-authorities_feature.deny" } + else + return allow() + end + end) + Roles.define_permission_trigger("core.admin", function(player, state) if state then Commands.unlock_system_commands(player.name) diff --git a/exp_scenario/module/commands/lawnmower.lua b/exp_scenario/module/commands/lawnmower.lua index 0dbba6f6..043825b6 100644 --- a/exp_scenario/module/commands/lawnmower.lua +++ b/exp_scenario/module/commands/lawnmower.lua @@ -4,9 +4,13 @@ Adds a command that clean up biter corpse and nuclear hole local AABB = require("modules/exp_util/aabb") local Commands = require("modules/exp_commands") +local Features = require("modules/exp_scenario/features") local Selection = require("modules/exp_util/selection") local SelectArea = Selection.connect("ExpCommand_Lawnmower") -local config = require("modules.exp_legacy.config.lawnmower") + +local config = Features.config("lawnmower", { + destroy_decoratives = false, --- @setting destroy_decoratives remove decoratives under entities when they are built +}) --- @class ExpCommand_Lawnmower.commands local commands = {} @@ -15,6 +19,7 @@ local commands = {} --- @class ExpCommands_Lawnmower.commands.lawnmower: ExpCommand --- @overload fun(player: LuaPlayer) commands.lawnmower = Commands.new("lawnmower", { "exp-commands_lawnmower.description" }) + :add_flags{ feature = config } :register(function(player) if SelectArea:stop(player) then return Commands.status.success{ "exp_util.selection_exit", { "exp-commands_lawnmower.selection-name" } } @@ -51,6 +56,7 @@ end) --- @param event EventData.on_built_entity | EventData.on_robot_built_entity | EventData.script_raised_built | EventData.script_raised_revive local function destroy_decoratives(event) + if not config.destroy_decoratives then return end local entity = event.entity if entity.type ~= "entity-ghost" and entity.type ~= "tile-ghost" and entity.prototype.selectable_in_game then entity.surface.destroy_decoratives{ area = entity.selection_box } @@ -58,16 +64,13 @@ local function destroy_decoratives(event) end local e = defines.events -local events = {} -if config.destroy_decoratives then - events[e.on_built_entity] = destroy_decoratives - events[e.on_robot_built_entity] = destroy_decoratives - events[e.script_raised_built] = destroy_decoratives - events[e.script_raised_revive] = destroy_decoratives -end - -return { - events = events, +return Features.guard(config, { + events = { + [e.on_built_entity] = destroy_decoratives, + [e.on_robot_built_entity] = destroy_decoratives, + [e.script_raised_built] = destroy_decoratives, + [e.script_raised_revive] = destroy_decoratives, + }, commands = commands, -} +}) diff --git a/exp_scenario/module/config/afk_kick.lua b/exp_scenario/module/config/afk_kick.lua index 8b3ecbd0..d961897c 100644 --- a/exp_scenario/module/config/afk_kick.lua +++ b/exp_scenario/module/config/afk_kick.lua @@ -4,13 +4,13 @@ local Features = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -return Features.register("afk_kick", { +return Features.config("afk_kick", { admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time afk_minutes = 10, --- @setting afk_minutes The time in minutes that must pass for a player to be considered afk kick_minutes = 30, --- @setting kick_minutes The time in minutes that must pass without any active players for all players to be kicked trust_minutes = 600, --- @setting trust_minutes The time in minutes that a player must be online for to count as trusted - update_time = 3600 * 30, --- @setting update_time How often in ticks the script checks for active players, needs a restart to change + update_seconds = 1800, --- @setting update_seconds How often in seconds the script checks for active players custom_active_check = function(player) local veteran = Roles.get_role_by_name("Veteran") return veteran ~= nil and not Roles.get_player_highest_role(player):is_lower_than(veteran) diff --git a/exp_scenario/module/config/chat_auto_reply.lua b/exp_scenario/module/config/chat_auto_reply.lua index 65916f9d..9d1c9799 100644 --- a/exp_scenario/module/config/chat_auto_reply.lua +++ b/exp_scenario/module/config/chat_auto_reply.lua @@ -25,7 +25,7 @@ local afk_time_units = { seconds = true, } -return Features.register("chat_auto_reply", { +return Features.config("chat_auto_reply", { --- @type table messages = { --- @setting messages will trigger when ever the word is said ["discord"] = { "info.discord", config_server_detail["discord"] }, diff --git a/exp_scenario/module/config/death_markers.lua b/exp_scenario/module/config/death_markers.lua deleted file mode 100644 index 58f6ee3c..00000000 --- a/exp_scenario/module/config/death_markers.lua +++ /dev/null @@ -1,15 +0,0 @@ ---- This config controls what happens when a player dies mostly about map markers and item collection --- @config Death-Markers - -local Features = require("modules/exp_scenario/features") - -return Features.register("death_markers", { - collect_corpses = true, --- @setting collect_corpses enables items being returned to the spawn point in chests upon corpse expiring - show_map_markers = true, --- @setting show_map_markers shows markers on the map where bodies are - clean_map_markers = false, --- @setting clean_map_markers removes the map marker once the body is gone - include_time_of_death = true, --- @setting include_time_of_death weather to include the time of death on the map marker - map_icon = nil, --- @setting map_icon the icon that the map marker shows; nil means no icon; format as a SingleID - show_light_at_corpse = true, --- @setting show_light_at_corpse if a light should be rendered at the corpse - show_line_to_corpse = true, --- @setting show_line_to_corpse if a line should be rendered from you to your corpse - period_check_map_tags = 60 * 60 * 5, --- @setting period_check_map_tags ticks between checks for missing map markers, needs a restart to change -}) diff --git a/exp_scenario/module/config/mine_depletion.lua b/exp_scenario/module/config/mine_depletion.lua deleted file mode 100644 index e0d2c086..00000000 --- a/exp_scenario/module/config/mine_depletion.lua +++ /dev/null @@ -1,10 +0,0 @@ ---- Settings for mine depletion --- @config Mine-Depletion - -local Features = require("modules/exp_scenario/features") - -return Features.register("mine_depletion", { - fluid = true, --- @setting fluid When true, checks for for fluid pipes when removing miners - chest = true, --- @setting chest When true, checks for for chest when removing miners - beacon = true, --- @setting beacon When true, checks for for beacon when removing miners -}) diff --git a/exp_scenario/module/config/station_auto_name.lua b/exp_scenario/module/config/station_auto_name.lua deleted file mode 100644 index 8de9e4e2..00000000 --- a/exp_scenario/module/config/station_auto_name.lua +++ /dev/null @@ -1,16 +0,0 @@ ---- Settings for naming train stations when they are built --- @config Station-Auto-Name - -local Features = require("modules/exp_scenario/features") - -return Features.register("station_auto_name", { - --[[ - __icon__ - __item_name__ - __backer_name__ - __direction__ - __x__ - __y__ - ]] - station_name = "[L] __icon__", --- @setting station_name the name given to new stations, uses the placeholders above -}) diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index db632016..24197c1a 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -11,6 +11,7 @@ require("modules/exp_scenario/commands/_types") --- Commands with events add(require("modules/exp_scenario/commands/protected_entities")) add(require("modules/exp_scenario/commands/protected_tags")) +add(require("modules/exp_scenario/commands/lawnmower")) --- Commands require("modules/exp_scenario/commands/admin_chat") @@ -24,7 +25,6 @@ require("modules/exp_scenario/commands/enemy") require("modules/exp_scenario/commands/home") require("modules/exp_scenario/commands/jail") require("modules/exp_scenario/commands/kill") -require("modules/exp_scenario/commands/lawnmower") require("modules/exp_scenario/commands/locate") require("modules/exp_scenario/commands/me") require("modules/exp_scenario/commands/rainbow") @@ -50,6 +50,7 @@ add(require("modules/exp_scenario/control/death_markers")) add(require("modules/exp_scenario/control/deconstruction_log")) add(require("modules/exp_scenario/control/degrading_tiles")) add(require("modules/exp_scenario/control/discord_alerts")) +add(require("modules/exp_scenario/control/features")) add(require("modules/exp_scenario/control/extra_logging")) add(require("modules/exp_scenario/control/fast_deconstruction")) add(require("modules/exp_scenario/control/help_bubbles")) diff --git a/exp_scenario/module/control/afk_kick.lua b/exp_scenario/module/control/afk_kick.lua index b44fad0c..926ef446 100644 --- a/exp_scenario/module/control/afk_kick.lua +++ b/exp_scenario/module/control/afk_kick.lua @@ -80,8 +80,8 @@ return Features.guard(config, { events = { [e.on_player_joined_game] = on_player_joined_game, }, - on_nth_tick = { - [config.update_time] = check_afk_players, + intervals = { + update_seconds = check_afk_players, }, has_active_player = has_active_player, }) diff --git a/exp_scenario/module/control/death_markers.lua b/exp_scenario/module/control/death_markers.lua index 5c586fae..cbe42a65 100644 --- a/exp_scenario/module/control/death_markers.lua +++ b/exp_scenario/module/control/death_markers.lua @@ -5,7 +5,17 @@ Makes markers on the map where places have died and reclaims items if not recove local ExpUtil = require("modules/exp_util") local Features = require("modules/exp_scenario/features") local Storage = require("modules/exp_util/storage") -local config = require("modules/exp_scenario/config/death_markers") + +local config = Features.config("death_markers", { + collect_corpses = true, --- @setting collect_corpses enables items being returned to the spawn point in chests upon corpse expiring + show_map_markers = true, --- @setting show_map_markers shows markers on the map where bodies are + clean_map_markers = false, --- @setting clean_map_markers removes the map marker once the body is gone + include_time_of_death = true, --- @setting include_time_of_death weather to include the time of death on the map marker + map_icon = nil, --- @setting map_icon the icon that the map marker shows; nil means no icon; format as a SingleID + show_light_at_corpse = true, --- @setting show_light_at_corpse if a light should be rendered at the corpse + show_line_to_corpse = true, --- @setting show_line_to_corpse if a line should be rendered from you to your corpse + check_map_tags_seconds = 300, --- @setting check_map_tags_seconds seconds between checks for missing map markers +}) local map_tag_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true } @@ -139,8 +149,8 @@ end local e = defines.events return Features.guard(config, { - on_nth_tick = { - [config.period_check_map_tags] = check_map_tags, + intervals = { + check_map_tags_seconds = check_map_tags, }, events = { [e.on_player_died] = on_player_died, diff --git a/exp_scenario/module/control/features.lua b/exp_scenario/module/control/features.lua new file mode 100644 index 00000000..49ec13da --- /dev/null +++ b/exp_scenario/module/control/features.lua @@ -0,0 +1,19 @@ +--[[-- Control - Features +Refreshes the gui of every player when a feature is enabled or disabled, see features.lua +]] + +local Features = require("modules/exp_scenario/features") +local Gui = require("modules/exp_gui") + +--- Toolbar buttons of disabled features are hidden, which also hides their left elements +local function on_feature_changed() + for _, player in pairs(game.connected_players) do + Gui._ensure_consistency{ player_index = player.index } + end +end + +return { + events = { + [Features.events.on_feature_changed] = on_feature_changed, + }, +} diff --git a/exp_scenario/module/control/mine_depletion.lua b/exp_scenario/module/control/mine_depletion.lua index 45701b8f..e1a1bfff 100644 --- a/exp_scenario/module/control/mine_depletion.lua +++ b/exp_scenario/module/control/mine_depletion.lua @@ -4,7 +4,12 @@ Marks mining drills for deconstruction when resources deplete local Async = require("modules/exp_util/async") local Features = require("modules/exp_scenario/features") -local config = require("modules/exp_scenario/config/mine_depletion") + +local config = Features.config("mine_depletion", { + fluid = true, --- @setting fluid When true, checks for for fluid pipes when removing miners + chest = true, --- @setting chest When true, checks for for chest when removing miners + beacon = true, --- @setting beacon When true, checks for for beacon when removing miners +}) local floor = math.floor diff --git a/exp_scenario/module/control/station_auto_name.lua b/exp_scenario/module/control/station_auto_name.lua index 0b135321..8d9273cc 100644 --- a/exp_scenario/module/control/station_auto_name.lua +++ b/exp_scenario/module/control/station_auto_name.lua @@ -3,7 +3,12 @@ Automatically name stations when they are placed based on closest resource and d ]] local Features = require("modules/exp_scenario/features") -local config = require("modules/exp_scenario/config/station_auto_name") + +local config = Features.config("station_auto_name", { + --- @setting station_name the name given to new stations, placeholders: + -- __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__ + station_name = "[L] __icon__", +}) local get_direction do local directions = { diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua index 38b5c5df..31927a56 100644 --- a/exp_scenario/module/features.lua +++ b/exp_scenario/module/features.lua @@ -1,32 +1,46 @@ --[[-- ExpScenario - Features -Feature config which the controller can change while the game is running +Features which the controller can enable, disable, and configure while the game is running -Each feature declares its defaults in config/.lua by returning the table -from Features.register. The controller sends overrides over rcon, these are kept -in storage and copied onto that same table, so read values when they are used -rather than copying them into locals when the file loads. +A feature's config is the table returned by Features.config. The controller sends +overrides over rcon, these are kept in storage and copied onto that same table, +so read values when they are used rather than copying them into locals when the +file loads. Only boolean, number, and string defaults can be overridden. -Only boolean, number, and string defaults can be overridden. Values used while -the file loads, such as on_nth_tick periods, can not change without a restart -and should not be listed in features.ts. +Values used while the file loads can not change without a restart. Periods can +use intervals instead of on_nth_tick, everything else such as toolbar sprites +stays as a plain value and is not listed in features.ts. ---- Declaring a feature: +--- Declaring a feature with config, small configs live at the top of the file which uses them: local Features = require("modules/exp_scenario/features") -return Features.register("my_feature", { +local config = Features.config("my_feature", { show_message = true, + check_seconds = 60, }) ---- Using it from a control file, handlers do nothing while it is disabled: -local config = require("modules/exp_scenario/config/my_feature") +--- Handlers do nothing while the feature is disabled, a feature without config can be guarded by name: return Features.guard(config, { events = { [defines.events.on_player_joined_game] = on_player_joined_game }, + intervals = { check_seconds = check_players }, -- Runs every config.check_seconds seconds }) + +--- Toolbar buttons are hidden while disabled, which also hides their left element: +Gui.toolbar.create_button{ + visible = function(player, element) return Features.is_enabled("my_feature") end, +} + +--- Commands are denied and left out of help while disabled: +Commands.new("my_command", { "..." }):add_flags{ feature = "my_feature" } ]] local Storage = require("modules/exp_util/storage") --- @class ExpScenario.Features -local Features = {} +local Features = { + events = { + --- Raised when the controller changes a feature, gui and commands are refreshed by control/features.lua + on_feature_changed = script.generate_event_name(), + }, +} local overridable = { boolean = true, @@ -37,6 +51,8 @@ local overridable = { --- @class ExpScenario.FeatureConfig --- @field enabled boolean +--- @alias ExpScenario.Feature string | ExpScenario.FeatureConfig + --- The live config tables by feature name local features = {} --- @type table @@ -70,13 +86,14 @@ Storage.register(overrides, function(tbl) end end) ---- Register a feature and its defaults, features are enabled unless the defaults say otherwise +--- Declare a feature and its config, features are enabled unless the config says otherwise --- @generic T : table --- @param name string ---- @param config T +--- @param config T? --- @return T -function Features.register(name, config) - assert(features[name] == nil, "Feature already registered: " .. name) +function Features.config(name, config) + assert(features[name] == nil, "Feature already declared: " .. name) + config = config or {} --[[@as T]] if config.enabled == nil then config.enabled = true end @@ -91,12 +108,55 @@ function Features.register(name, config) return config end ---- Wrap the event and on_nth_tick handlers of an event handler lib so they do nothing while the feature is disabled +--- Get the config of a feature from its name, or return the config given +--- @param feature ExpScenario.Feature +--- @return ExpScenario.FeatureConfig +local function resolve(feature) + if type(feature) == "table" then + return feature + end + return features[feature] or error("Unknown feature: " .. tostring(feature), 3) +end + +--- Check if a feature is enabled +--- @param feature ExpScenario.Feature +--- @return boolean +function Features.is_enabled(feature) + return resolve(feature).enabled +end + +--- Make the handlers of an event handler lib do nothing while the feature is disabled +-- A name which has not been declared is declared without config. The intervals field +-- maps config keys to handlers, each runs every config[key] seconds, checked once a second --- @generic T : table ---- @param config ExpScenario.FeatureConfig +--- @param feature ExpScenario.Feature --- @param lib T --- @return T -function Features.guard(config, lib) +function Features.guard(feature, lib) + if type(feature) == "string" and features[feature] == nil then + Features.config(feature) + end + local config = resolve(feature) + + local intervals = lib.intervals + if intervals then + lib.intervals = nil + lib.on_nth_tick = lib.on_nth_tick or {} + local every_second = lib.on_nth_tick[60] + lib.on_nth_tick[60] = function(event) + if every_second then + every_second(event) + end + -- Exactly one check each period lands within its first second + local tick = event.tick + for key, handler in pairs(intervals) do + if tick % (config[key] * 60) < 60 then + handler(event) + end + end + end + end + for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do for key, handler in pairs(handlers) do handlers[key] = function(event) @@ -128,6 +188,7 @@ function Features.receive_update(updates) overrides[name] = override apply(name) + script.raise_event(Features.events.on_feature_changed, { feature_name = name }) end end end diff --git a/exp_scenario/module/gui/autofill.lua b/exp_scenario/module/gui/autofill.lua index 8c663af4..f8b2a162 100644 --- a/exp_scenario/module/gui/autofill.lua +++ b/exp_scenario/module/gui/autofill.lua @@ -2,6 +2,7 @@ Adds a config menu for setting autofill of placed entities ]] +local Features = require("modules/exp_scenario/features") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.gui.autofill") @@ -330,7 +331,7 @@ Gui.toolbar.create_button{ sprite = config.icon, tooltip = { "exp-gui_autofill.tooltip-main" }, visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.autofill") + return Features.is_enabled("autofill") and Roles.player_has_permission(player, "exp_scenario.gui.autofill") end } @@ -396,9 +397,9 @@ end local e = defines.events -return { +return Features.guard("autofill", { elements = Elements, events = { [e.on_built_entity] = on_built_entity, - } -} + }, +}) diff --git a/exp_scenario/module/gui/quick_actions.lua b/exp_scenario/module/gui/quick_actions.lua index 2d9c7cdb..9a94bd9f 100644 --- a/exp_scenario/module/gui/quick_actions.lua +++ b/exp_scenario/module/gui/quick_actions.lua @@ -4,6 +4,7 @@ Adds a few buttons for common actions local Gui = require("modules/exp_gui") local Commands = require("modules/exp_commands") +local Features = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local addon_artillery = require("modules/exp_scenario/commands/artillery") @@ -126,9 +127,17 @@ local function on_role_changed(event) Elements.container.refresh_player(player) end +--- Actions for commands of a disabled feature are hidden +local function on_feature_changed() + for _, player in pairs(game.connected_players) do + Elements.container.refresh_player(player) + end +end + return { elements = Elements, events = { [Roles.events.on_player_roles_changed] = on_role_changed, + [Features.events.on_feature_changed] = on_feature_changed, } } diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 4d3fe9db..1917b254 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -1,6 +1,9 @@ [exp-commands-authorities_role] deny=Unauthorized, Access is denied due to missing permissions. +[exp-commands-authorities_feature] +deny=This command is disabled on this server. + [exp-commands-parse_role] lower-role=Role is higher than your highest. lower-role-player=Player has a higher role. diff --git a/exp_scenario/test/features.test.js b/exp_scenario/test/features.test.js index e5139e13..c33c8049 100644 --- a/exp_scenario/test/features.test.js +++ b/exp_scenario/test/features.test.js @@ -3,7 +3,20 @@ import path from "node:path"; import t from "tap"; import { features, validateFeatureValues } from "../dist/node/features.js"; -/** Read a literal default from a module/config file, only covers single line booleans, numbers, and strings. */ +/** Every lua file in the module, by path relative to it. */ +function luaFiles(dir, prefix = "") { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const relative = path.posix.join(prefix, entry.name); + if (entry.isDirectory()) { + return luaFiles(path.join(dir, entry.name), relative); + } + return entry.name.endsWith(".lua") ? [[relative, fs.readFileSync(path.join(dir, entry.name), "utf8")]] : []; + }); +} + +const moduleFiles = luaFiles(path.join(import.meta.dirname, "..", "module")); + +/** Read a literal default from a Features.config call, only covers single line booleans, numbers, and strings. */ function luaDefault(source, key) { const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"),`, "m").exec(source); return match ? JSON.parse(match[1]) : undefined; @@ -12,9 +25,11 @@ function luaDefault(source, key) { t.test("features", t2 => { for (const feature of features) { t2.test(feature.name, t3 => { - const file = path.join(import.meta.dirname, "..", "module", "config", `${feature.name}.lua`); - const source = fs.readFileSync(file, "utf8"); - t3.match(source, `Features.register("${feature.name}"`, "the config file registers the feature"); + const declarations = moduleFiles.filter(([, source]) => ( + source.includes(`Features.config("${feature.name}"`) || source.includes(`Features.guard("${feature.name}"`) + )); + t3.equal(declarations.length, 1, "one lua file declares the feature"); + const source = declarations[0]?.[1] ?? ""; for (const field of feature.fields) { t3.equal(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`); } @@ -32,8 +47,8 @@ t.test("validateFeatureValues()", t2 => { ); t2.throws(() => validateFeatureValues("unknown", {}), { message: "Unknown feature unknown" }); t2.throws( - () => validateFeatureValues("death_markers", { period_check_map_tags: 60 }), - { message: "Feature death_markers has no setting period_check_map_tags" }, + () => validateFeatureValues("death_markers", { map_icon: "x" }), + { message: "Feature death_markers has no setting map_icon" }, ); t2.throws( () => validateFeatureValues("death_markers", { show_map_markers: "no" }), diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua index 4cb14eba..58de21b1 100644 --- a/exp_scenario/test/module/features.lua +++ b/exp_scenario/test/module/features.lua @@ -1,13 +1,21 @@ local Suite = ... --- @type Suite -Suite.test("register() enables features unless the defaults say otherwise", function(env) - Suite.eq(env.Features.register("on", { value = 1 }).enabled, true, "enabled by default") - Suite.eq(env.Features.register("off", { enabled = false }).enabled, false, "the defaults can disable it") - Suite.throws(function() env.Features.register("on", {}) end, "Feature already registered: on", "names are unique") +Suite.test("config() enables features unless the defaults say otherwise", function(env) + Suite.eq(env.Features.config("on", { value = 1 }).enabled, true, "enabled by default") + Suite.eq(env.Features.config("off", { enabled = false }).enabled, false, "the defaults can disable it") + Suite.eq(env.Features.config("empty"), { enabled = true }, "config is optional") + Suite.throws(function() env.Features.config("on", {}) end, "Feature already declared: on", "names are unique") end) -Suite.test("receive_update() changes the registered table in place", function(env) - local config = env.Features.register("test", { flag = true, count = 5, text = "a" }) +Suite.test("is_enabled() takes a name or a config", function(env) + local config = env.Features.config("test", { enabled = false }) + Suite.eq(env.Features.is_enabled("test"), false, "by name") + Suite.eq(env.Features.is_enabled(config), false, "by config") + Suite.throws(function() env.Features.is_enabled("unknown") end, "Unknown feature: unknown", "unknown names error") +end) + +Suite.test("receive_update() changes the config in place", function(env) + local config = env.Features.config("test", { flag = true, count = 5, text = "a" }) env.Features.receive_update{ { name = "test", enabled = false, values = { flag = false, count = 7 } } } Suite.eq(config, { enabled = false, flag = false, count = 7, text = "a" }, "given values replace the defaults") @@ -16,9 +24,20 @@ Suite.test("receive_update() changes the registered table in place", function(en Suite.eq(env.storage, { test = { enabled = true, text = "b" } }, "the overrides are kept in storage") end) +Suite.test("receive_update() raises on_feature_changed for each known feature", function(env) + env.Features.config("test") + env.Features.receive_update{ + { name = "test", enabled = false, values = {} }, + { name = "unknown", enabled = false, values = {} }, + } + Suite.eq(env.events, { + { name = env.Features.events.on_feature_changed, tick = game.tick, feature_name = "test" }, + }, "raised once") +end) + Suite.test("receive_update() ignores values which can not be overridden", function(env) local function check() return true end - local config = env.Features.register("test", { flag = true, check = check, icon = nil }) + local config = env.Features.config("test", { flag = true, check = check, icon = nil }) env.Features.receive_update{ { name = "unknown", enabled = false, values = {} }, { name = "test", enabled = true, values = { flag = "yes", check = false, icon = "x", extra = 1 } }, @@ -28,7 +47,7 @@ Suite.test("receive_update() ignores values which can not be overridden", functi end) Suite.test("on_load applies the overrides from storage", function(env) - local config = env.Features.register("test", { flag = true }) + local config = env.Features.config("test", { flag = true }) env.on_load{ test = { enabled = false, flag = false } } Suite.eq(config, { enabled = false, flag = false }, "the saved overrides apply") @@ -37,7 +56,7 @@ Suite.test("on_load applies the overrides from storage", function(env) end) Suite.test("guard() skips handlers while the feature is disabled", function(env) - local config = env.Features.register("test", {}) + local config = env.Features.config("test") local calls = {} local lib = env.Features.guard(config, { events = { on_event = function(event) calls[#calls + 1] = event end }, @@ -53,11 +72,44 @@ Suite.test("guard() skips handlers while the feature is disabled", function(env) Suite.eq(calls, { "a", "b" }, "only called while enabled") end) -Suite.test("config files register their feature", function(env) - local config = env.load_config("death_markers") - env.Features.receive_update{ { name = "death_markers", enabled = true, values = { show_map_markers = false } } } - Suite.eq(config.show_map_markers, false, "death_markers can be changed") - Suite.eq(config.period_check_map_tags, 60 * 60 * 5, "other values keep their default") +Suite.test("guard() declares features by name", function(env) + local calls = 0 + local lib = env.Features.guard("test", { events = { on_event = function() calls = calls + 1 end } }) + env.Features.receive_update{ { name = "test", enabled = false, values = {} } } + lib.events.on_event() + Suite.eq(calls, 0, "the name is declared without config") + Suite.eq(env.Features.is_enabled("test"), false, "and can be looked up") +end) + +Suite.test("guard() runs intervals once per period of the config value in seconds", function(env) + local config = env.Features.config("test", { every = 5 }) + local ticks, seconds = {}, {} + local lib = env.Features.guard(config, { + on_nth_tick = { [60] = function(event) seconds[#seconds + 1] = event.tick end }, + intervals = { every = function(event) ticks[#ticks + 1] = event.tick end }, + }) + Suite.eq(lib.intervals, nil, "intervals are replaced by on_nth_tick") + + local function run_until(last) + for tick = 60, last, 60 do lib.on_nth_tick[60]{ tick = tick } end + end + + run_until(1200) + Suite.eq(ticks, { 300, 600, 900, 1200 }, "every 5 seconds") + Suite.eq(#seconds, 20, "an existing handler every second still runs") + + ticks = {} + env.Features.receive_update{ { name = "test", enabled = true, values = { every = 7 } } } + run_until(1260) + Suite.eq(ticks, { 420, 840, 1260 }, "the period can change at runtime") +end) + +Suite.test("config files declare their feature", function(env) + env.extend_requires{ ["modules/exp_roles"] = {} } + local config = env.load_config("afk_kick") + env.Features.receive_update{ { name = "afk_kick", enabled = true, values = { afk_minutes = 3 } } } + Suite.eq(config.afk_minutes, 3, "afk_kick can be changed") + Suite.eq(config.kick_minutes, 30, "other values keep their default") end) return Suite.run() From 4bc1773c8290d435fffd80007b4013306e1ee2a0 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:28:23 +0000 Subject: [PATCH 03/11] Address review: page layout, five more features, framework changes Framework: - Intervals are gone. Update periods are hardcoded on_nth_tick values again, since nobody has needed to change them. - apply is a single loop over the declared fields and logs a warning when an override is invalid. receive_update also warns about unknown keys. - Nested tables are addressed as "section.key". Lists of strings can be overridden. Features.optional declares a value with no default. - Features.on_apply rebuilds values derived from the config, such as lookup sets. It also runs during on_load. exp_roles is assumed to be present: - AFK Kick picks a role (active_role_id) instead of a custom check function. - Chat Auto Reply, Protection, and Nuke Protection no longer take a permission as a setting. New features, each chosen for an edge case: - Discord Alerts: conditional handlers, and a command map that becomes a list. - Entity Protection: lists turned into sets at load, and bookkeeping that must keep running while disabled. - Nuke Protection: config keyed by defines, flattened to a banned items list. - Popups: one config shared by two control files. - Spawn Area: nested sections, and settings that only apply when the map is created. Page: now laid out like the role page. It has a search across every setting, one long list with a sticky sidebar to jump between sections, and the enabled switch separated at the front of each header with Reset to defaults on the right. Changed rows are highlighted, and a bottom bar offers Revert and Apply. Role settings use a role picker and lists use a tag select. Co-Authored-By: Claude Opus 5.5 --- exp_legacy/module/config/discord_alerts.lua | 23 - exp_legacy/module/config/nukeprotect.lua | 30 -- exp_legacy/module/config/protection.lua | 17 - exp_scenario/features.ts | 327 +++++++------- exp_scenario/messages.ts | 7 +- exp_scenario/module/config/afk_kick.lua | 18 - .../module/config/chat_auto_reply.lua | 1 - .../module/config/popups.lua | 10 +- .../module/config/spawn_area.lua | 8 +- exp_scenario/module/control/afk_kick.lua | 24 +- .../module/control/chat_auto_reply.lua | 2 +- exp_scenario/module/control/chat_popup.lua | 9 +- exp_scenario/module/control/damage_popups.lua | 7 +- exp_scenario/module/control/death_markers.lua | 5 +- .../module/control/discord_alerts.lua | 306 +++++++------ .../module/control/nuke_protection.lua | 49 ++- exp_scenario/module/control/protection.lua | 46 +- exp_scenario/module/control/spawn_area.lua | 45 +- exp_scenario/module/features.lua | 194 ++++++--- exp_scenario/test/features.test.js | 51 ++- exp_scenario/test/module/env.lua | 3 + exp_scenario/test/module/features.lua | 68 ++- exp_scenario/web/components/FeaturesPage.tsx | 409 +++++++++++++----- 23 files changed, 989 insertions(+), 670 deletions(-) delete mode 100644 exp_legacy/module/config/discord_alerts.lua delete mode 100644 exp_legacy/module/config/nukeprotect.lua delete mode 100644 exp_legacy/module/config/protection.lua delete mode 100644 exp_scenario/module/config/afk_kick.lua rename exp_legacy/module/config/popup_messages.lua => exp_scenario/module/config/popups.lua (74%) rename {exp_legacy => exp_scenario}/module/config/spawn_area.lua (99%) diff --git a/exp_legacy/module/config/discord_alerts.lua b/exp_legacy/module/config/discord_alerts.lua deleted file mode 100644 index bc51d78c..00000000 --- a/exp_legacy/module/config/discord_alerts.lua +++ /dev/null @@ -1,23 +0,0 @@ ---- Config file used to enable and disable different push messages for discord --- @config Discord-Alerts - -return { - show_playtime = true, - entity_protection = true, - player_bans = true, - player_mutes = true, - player_kicks = true, - player_promotes = false, - player_jail = true, - ["config"] = true, - ["purge"] = true, - ["c"] = true, - ["command"] = true, - ["silent-command"] = true, - ["measured-command"] = true, - ["banlist"] = true, - ["permissions"] = true, - ["editor"] = true, - ["cheat"] = true, - ["open"] = false, -} diff --git a/exp_legacy/module/config/nukeprotect.lua b/exp_legacy/module/config/nukeprotect.lua deleted file mode 100644 index 197ddb00..00000000 --- a/exp_legacy/module/config/nukeprotect.lua +++ /dev/null @@ -1,30 +0,0 @@ -return { - inventories = { - { - inventory = defines.inventory.character_ammo, - event = defines.events.on_player_ammo_inventory_changed, - items = { - ["atomic-bomb"] = true, - }, - }, - { - inventory = defines.inventory.character_armor, - event = defines.events.on_player_armor_inventory_changed, - items = {}, - }, - { - inventory = defines.inventory.character_guns, - event = defines.events.on_player_gun_inventory_changed, - items = {}, - }, - { - inventory = defines.inventory.character_main, - event = defines.events.on_player_main_inventory_changed, - items = { - ["atomic-bomb"] = true, - }, - }, - }, - ignore_permission = "exp_scenario.bypass.nuke_protection", -- @setting ignore_permission The permission that nukeprotect will ignore - ignore_admins = true, -- @setting ignore_admins Ignore admins, true by default. Allows usage outside of the roles module -} diff --git a/exp_legacy/module/config/protection.lua b/exp_legacy/module/config/protection.lua deleted file mode 100644 index d645d04b..00000000 --- a/exp_legacy/module/config/protection.lua +++ /dev/null @@ -1,17 +0,0 @@ -return { - ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter - ignore_permission = "exp_scenario.bypass.entity_protection", --- @setting ignore_permission Players with this permission will be ignored by the protection filter, leave nil to protect against every player - repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_lifetime in order to trigger repeated removal protection - repeat_lifetime = 3600 * 20, --- @setting repeat_lifetime The length of time, in ticks, that protected removals will be remembered for - refresh_rate = 3600 * 5, --- @setting refresh_rate How often the age of protected removals are checked against repeat_lifetime - always_protected_names = { --- @setting always_protected_names Names of entities which are always protected - }, - always_protected_types = { --- @setting always_protected_types Types of entities which are always protected - "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", - }, - always_trigger_repeat_names = { --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection - }, - always_trigger_repeat_types = { --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection - "reactor", "fusion-reactor", "rocket-silo", - }, -} diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts index c20b6459..fa97660b 100644 --- a/exp_scenario/features.ts +++ b/exp_scenario/features.ts @@ -3,10 +3,10 @@ * * Defaults must match the Features.config call in the lua module, * test/features.test.js checks this. Only list settings which the lua side - * reads at runtime. + * reads at runtime. Settings in nested tables are named "section.key". */ -export type FeatureValue = boolean | number | string; +export type FeatureValue = boolean | number | string | string[] | null; export type FeatureField = { name: string, @@ -16,6 +16,9 @@ export type FeatureField = { | { type: "boolean", default: boolean } | { type: "number", default: number, min?: number, unit?: string } | { type: "string", default: string } + | { type: "string_list", default: string[] } + /** Id of a clusterio role, null when no role is picked */ + | { type: "role", default: null } ); export type Feature = { @@ -25,62 +28,38 @@ export type Feature = { fields: FeatureField[], }; +function bool(name: string, title: string, description: string, value: boolean): FeatureField { + return { name, title, description, type: "boolean", default: value }; +} + +function num(name: string, title: string, description: string, value: number, min?: number, unit?: string): FeatureField { + return { name, title, description, type: "number", default: value, min, unit }; +} + +function str(name: string, title: string, description: string, value: string): FeatureField { + return { name, title, description, type: "string", default: value }; +} + +function list(name: string, title: string, description: string, value: string[]): FeatureField { + return { name, title, description, type: "string_list", default: value }; +} + +function role(name: string, title: string, description: string): FeatureField { + return { name, title, description, type: "role", default: null }; +} + export const features: Feature[] = [ { name: "afk_kick", title: "AFK Kick", description: "Kick everyone when nobody online is active", fields: [ - { - name: "admin_as_active", - title: "Admins count as active", - description: "Admins are active regardless of how long they have been afk", - type: "boolean", - default: true, - }, - { - name: "trust_as_active", - title: "Trusted players count as active", - description: "Players with enough playtime are active regardless of how long they have been afk", - type: "boolean", - default: true, - }, - { - name: "afk_minutes", - title: "AFK after", - description: "Time without input before a player counts as afk", - type: "number", - default: 10, - min: 1, - unit: "minutes", - }, - { - name: "kick_minutes", - title: "Kick after", - description: "Time without any active player before everyone is kicked", - type: "number", - default: 30, - min: 1, - unit: "minutes", - }, - { - name: "trust_minutes", - title: "Trusted after", - description: "Playtime needed before a player counts as trusted", - type: "number", - default: 600, - min: 0, - unit: "minutes", - }, - { - name: "update_seconds", - title: "Check every", - description: "How often to check for active players", - type: "number", - default: 1800, - min: 1, - unit: "seconds", - }, + bool("admin_as_active", "Admins count as active", "Admins are active regardless of how long they have been afk", true), + bool("trust_as_active", "Trusted players count as active", "Players with enough playtime are active regardless of how long they have been afk", true), + role("active_role_id", "Role counts as active", "Players with this role or higher are active regardless of how long they have been afk"), + num("afk_minutes", "AFK after", "Time without input before a player counts as afk", 10, 1, "minutes"), + num("kick_minutes", "Kick after", "Time without any active player before everyone is kicked", 30, 1, "minutes"), + num("trust_minutes", "Trusted after", "Playtime needed before a player counts as trusted", 600, 0, "minutes"), ], }, { @@ -94,20 +73,8 @@ export const features: Feature[] = [ title: "Chat Auto Reply", description: "Reply to key words in chat and run chat commands", fields: [ - { - name: "command_prefix", - title: "Command prefix", - description: "Prefix for chat commands, and for key words to reply to everyone", - type: "string", - default: "!", - }, - { - name: "command_admin_only", - title: "Commands for admins only", - description: "Only admins can use chat commands", - type: "boolean", - default: false, - }, + str("command_prefix", "Command prefix", "Prefix for chat commands, and for key words to reply to everyone", "!"), + bool("command_admin_only", "Commands for admins only", "Only admins can use chat commands", false), ], }, { @@ -115,57 +82,29 @@ export const features: Feature[] = [ title: "Death Markers", description: "What happens when players die", fields: [ - { - name: "collect_corpses", - title: "Collect corpses", - description: "Put the items of expired corpses in chests", - type: "boolean", - default: true, - }, - { - name: "show_map_markers", - title: "Show map markers", - description: "Add a map marker where a player died", - type: "boolean", - default: true, - }, - { - name: "clean_map_markers", - title: "Remove map markers", - description: "Remove the map marker once the corpse is gone", - type: "boolean", - default: false, - }, - { - name: "include_time_of_death", - title: "Include time of death", - description: "Add the time of death to the map marker", - type: "boolean", - default: true, - }, - { - name: "show_light_at_corpse", - title: "Light at corpse", - description: "Draw a light in the player's colour at their corpse", - type: "boolean", - default: true, - }, - { - name: "show_line_to_corpse", - title: "Line to corpse", - description: "Draw a line from a respawned player to their corpse", - type: "boolean", - default: true, - }, - { - name: "check_map_tags_seconds", - title: "Check map markers every", - description: "How often to replace map markers which were removed", - type: "number", - default: 300, - min: 1, - unit: "seconds", - }, + bool("collect_corpses", "Collect corpses", "Put the items of expired corpses in chests", true), + bool("show_map_markers", "Show map markers", "Add a map marker where a player died", true), + bool("clean_map_markers", "Remove map markers", "Remove the map marker once the corpse is gone", false), + bool("include_time_of_death", "Include time of death", "Add the time of death to the map marker", true), + bool("show_light_at_corpse", "Light at corpse", "Draw a light in the player's colour at their corpse", true), + bool("show_line_to_corpse", "Line to corpse", "Draw a line from a respawned player to their corpse", true), + ], + }, + { + name: "discord_alerts", + title: "Discord Alerts", + description: "Write alerts for the discord bot when moderation events happen", + fields: [ + bool("show_playtime", "Show playtime", "Add the playtime of players to alerts", true), + bool("entity_protection", "Entity protection", "Alert when a player repeatedly removes protected entities", true), + bool("player_bans", "Bans", "Alert when a player is banned or unbanned", true), + bool("player_mutes", "Mutes", "Alert when a player is muted or unmuted", true), + bool("player_kicks", "Kicks", "Alert when a player is kicked", true), + bool("player_promotes", "Promotions", "Alert when a player is promoted or demoted", false), + bool("player_jail", "Jail", "Alert when a player is jailed or unjailed", true), + list("logged_commands", "Logged commands", "Alert when a player uses one of these commands", [ + "config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat", + ]), ], }, { @@ -173,13 +112,7 @@ export const features: Feature[] = [ title: "Lawnmower", description: "The /lawnmower command which clears corpses and nuclear ground", fields: [ - { - name: "destroy_decoratives", - title: "Clear decoratives when building", - description: "Remove decoratives under entities when they are built", - type: "boolean", - default: false, - }, + bool("destroy_decoratives", "Clear decoratives when building", "Remove decoratives under entities when they are built", false), ], }, { @@ -187,27 +120,76 @@ export const features: Feature[] = [ title: "Mine Depletion", description: "Deconstruct mining drills when their resources run out", fields: [ - { - name: "chest", - title: "Remove output chests", - description: "Also deconstruct the chest a drill outputs into", - type: "boolean", - default: true, - }, - { - name: "beacon", - title: "Remove beacons", - description: "Also deconstruct beacons which no longer affect anything", - type: "boolean", - default: true, - }, - { - name: "fluid", - title: "Replace with pipes", - description: "Place pipe ghosts where a drill carried fluid", - type: "boolean", - default: true, - }, + bool("chest", "Remove output chests", "Also deconstruct the chest a drill outputs into", true), + bool("beacon", "Remove beacons", "Also deconstruct beacons which no longer affect anything", true), + bool("fluid", "Replace with pipes", "Place pipe ghosts where a drill carried fluid", true), + ], + }, + { + name: "nuke_protection", + title: "Nuke Protection", + description: "Remove banned items from players without the nuke protection bypass permission", + fields: [ + bool("ignore_admins", "Ignore admins", "Admins can hold banned items", true), + list("banned_items", "Banned items", "Items which are moved to a chest at spawn", ["atomic-bomb"]), + ], + }, + { + name: "popups", + title: "Popups", + description: "Flying text for chat messages and damage", + fields: [ + bool("show_player_messages", "Chat messages", "Show chat messages above the player who sent them", true), + bool("show_player_mentions", "Mentions", "Show a popup above players mentioned in chat", true), + bool("show_player_damage", "Damage dealt", "Show the damage players deal to entities", true), + bool("show_player_health", "Player health", "Show the health of players when they are attacked", true), + num("damage_location_variance", "Damage spread", "How far from the centre of an entity damage popups appear, as a fraction of its size", 0.8, 0), + ], + }, + { + name: "protection", + title: "Entity Protection", + description: "Stop players removing protected entities placed by others, players with the bypass permission are ignored", + fields: [ + bool("ignore_admins", "Ignore admins", "Admins can remove protected entities", true), + num("repeat_count", "Repeat count", "Protected entities removed within the repeat time which count as a repeat violation", 5, 1), + num("repeat_minutes", "Repeat time", "How long removed protected entities are remembered", 20, 1, "minutes"), + list("always_protected_names", "Protected entity names", "Entities which are always protected", []), + list("always_protected_types", "Protected entity types", "Entity types which are always protected", [ + "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", + ]), + list("always_trigger_repeat_names", "Repeat entity names", "Entities which count as a repeat violation straight away", []), + list("always_trigger_repeat_types", "Repeat entity types", "Entity types which count as a repeat violation straight away", [ + "reactor", "fusion-reactor", "rocket-silo", + ]), + ], + }, + { + name: "spawn_area", + title: "Spawn Area", + description: "Build the spawn area when the first player joins, only the turret and resource refills apply to an existing map", + fields: [ + num("spawn_area.deconstruction_radius", "Clear radius", "Entities within this radius are removed", 20, 0, "tiles"), + num("spawn_area.tile_radius", "Tile radius", "Tiles within this radius are replaced", 20, 0, "tiles"), + str("spawn_area.deconstruction_tile", "Tile", "Tile placed within the tile radius", "concrete"), + num("spawn_area.landfill_radius", "Landfill radius", "Water within this radius is filled", 50, 0, "tiles"), + bool("turrets.enabled", "Turrets", "Place turrets at spawn and keep them refilled", true), + str("turrets.ammo_type", "Turret ammo", "Ammo used to refill the turrets", "uranium-rounds-magazine"), + bool("afk_belts.enabled", "AFK belts", "Place belt loops at spawn", true), + str("afk_belts.belt_type", "Belt type", "Belt used for the loops", "transport-belt"), + bool("afk_belts.protected", "Protect belts", "Players can not remove the belts", true), + bool("water.enabled", "Water", "Place water tiles at spawn", true), + str("water.water_tile", "Water tile", "Tile used for the water", "water-mud"), + bool("entities.enabled", "Entities", "Place walls, chests, lamps, and poles at spawn", true), + bool("entities.protected", "Protect entities", "Players can not remove the entities", true), + bool("entities.operable", "Operable entities", "Players can open the entities, needed for the chests", true), + bool("pattern.enabled", "Pattern", "Place a tile pattern at spawn", true), + str("pattern.pattern_tile", "Pattern tile", "Tile used for the pattern", "stone-path"), + bool("resource_refill_nearby.enabled", "Refill resources", "Add to resources near spawn every 10 minutes", false), + num("resource_refill_nearby.range", "Refill range", "Distance from spawn which resources are refilled", 128, 0, "tiles"), + list("resource_refill_nearby.resources_name", "Refilled resources", "Resources which are refilled", [ + "iron-ore", "copper-ore", "stone", "coal", "uranium-ore", + ]), ], }, { @@ -215,13 +197,7 @@ export const features: Feature[] = [ title: "Station Auto Name", description: "Name train stops when they are built", fields: [ - { - name: "station_name", - title: "Station name", - description: "Placeholders: __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__", - type: "string", - default: "[L] __icon__", - }, + str("station_name", "Station name", "Placeholders: __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__", "[L] __icon__"), ], }, ]; @@ -244,18 +220,45 @@ export function validateFeatureValues(name: string, values: Record typeof item !== "string")) { + throw new Error(`${label} must be a list of strings`); + } + return; + case "role": + if (value !== null && !Number.isInteger(value)) { + throw new Error(`${label} must be a role id or null`); + } + return; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number`); + } + if (field.min !== undefined && value < field.min) { + throw new Error(`${label} must be at least ${field.min}`); + } + return; + default: + if (typeof value !== field.type) { + throw new Error(`${label} must be a ${field.type}`); + } + } +} + +/** Check if a value equals the default of its field, lists are compared by content. */ +export function isDefaultValue(field: FeatureField, value: FeatureValue) { + if (Array.isArray(value) && Array.isArray(field.default)) { + return value.length === field.default.length && value.every((item, index) => item === (field.default as string[])[index]); + } + return value === field.default; +} diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index 17fdfc62..52feae15 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -1,5 +1,6 @@ import * as lib from "@clusterio/lib"; import { Type, Static } from "@sinclair/typebox"; +import type { FeatureValue } from "./features.js"; /** Create the roles and permission groups the scenario shipped with, see seed.ts. */ export class SeedRequest { @@ -13,7 +14,7 @@ export class SeedRequest { constructor() {} } -const FeatureValueSchema = Type.Union([Type.Boolean(), Type.Number(), Type.String()]); +const FeatureValueSchema = Type.Union([Type.Boolean(), Type.Number(), Type.String(), Type.Array(Type.String()), Type.Null()]); /** * The config of one scenario feature, see features.ts. @@ -26,7 +27,7 @@ export class FeatureRecord { /** Name of the feature in features.ts */ public id: string, public enabled: boolean, - public values: Record = {}, + public values: Record = {}, public updatedAtMs: number = 0, public isDeleted: boolean = false, ) {} @@ -117,7 +118,7 @@ export class FeatureUpdateRequest { constructor( public id: string, public enabled: boolean, - public values: Record, + public values: Record, ) {} static jsonSchema = Type.Object({ diff --git a/exp_scenario/module/config/afk_kick.lua b/exp_scenario/module/config/afk_kick.lua deleted file mode 100644 index d961897c..00000000 --- a/exp_scenario/module/config/afk_kick.lua +++ /dev/null @@ -1,18 +0,0 @@ ---- Settings for kicking players when everyone online is afk --- @config AFK-Kick - -local Features = require("modules/exp_scenario/features") -local Roles = require("modules/exp_roles") - -return Features.config("afk_kick", { - admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time - trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time - afk_minutes = 10, --- @setting afk_minutes The time in minutes that must pass for a player to be considered afk - kick_minutes = 30, --- @setting kick_minutes The time in minutes that must pass without any active players for all players to be kicked - trust_minutes = 600, --- @setting trust_minutes The time in minutes that a player must be online for to count as trusted - update_seconds = 1800, --- @setting update_seconds How often in seconds the script checks for active players - custom_active_check = function(player) - local veteran = Roles.get_role_by_name("Veteran") - return veteran ~= nil and not Roles.get_player_highest_role(player):is_lower_than(veteran) - end, -}) diff --git a/exp_scenario/module/config/chat_auto_reply.lua b/exp_scenario/module/config/chat_auto_reply.lua index 9d1c9799..344d0fc2 100644 --- a/exp_scenario/module/config/chat_auto_reply.lua +++ b/exp_scenario/module/config/chat_auto_reply.lua @@ -67,7 +67,6 @@ return Features.config("chat_auto_reply", { }, allow_command_prefix_for_messages = true, --- @setting allow_command_prefix_for_messages when true any message trigger will print to all player when prefixed command_admin_only = false, --- @setting command_admin_only when true will only allow chat commands for admins - command_permission = "exp_scenario.chat.commands", --- @setting command_permission the permission used to allow command prefixes command_prefix = "!", --- @setting command_prefix prefix used for commands below and to print to all players (if enabled above) --- @type table commands = { --- @setting commands will trigger only when command prefix is given diff --git a/exp_legacy/module/config/popup_messages.lua b/exp_scenario/module/config/popups.lua similarity index 74% rename from exp_legacy/module/config/popup_messages.lua rename to exp_scenario/module/config/popups.lua index 3a098d0e..60471cad 100644 --- a/exp_legacy/module/config/popup_messages.lua +++ b/exp_scenario/module/config/popups.lua @@ -1,10 +1,12 @@ ---- A combination of config settings for different popup values like chat and damage --- @config Popup-Messages +--- Popups for chat and damage, shared by control/chat_popup.lua and control/damage_popups.lua +-- @config Popups -return { +local Features = require("modules/exp_scenario/features") + +return Features.config("popups", { show_player_messages = true, --- @setting show_player_messages weather a message in chat will make a popup above them show_player_mentions = true, --- @setting show_player_mentions weather a mentioned player will have a popup when mentioned in chat show_player_damage = true, --- @setting show_player_damage weather to show damage done by players show_player_health = true, --- @setting show_player_health weather to show player health when attacked damage_location_variance = 0.8, --- @setting damage_location_variance how close to the eade of an entity the popups will appear -} +}) diff --git a/exp_legacy/module/config/spawn_area.lua b/exp_scenario/module/config/spawn_area.lua similarity index 99% rename from exp_legacy/module/config/spawn_area.lua rename to exp_scenario/module/config/spawn_area.lua index 72ee8c8e..aaca027c 100644 --- a/exp_legacy/module/config/spawn_area.lua +++ b/exp_scenario/module/config/spawn_area.lua @@ -1,7 +1,9 @@ --- Used to config the spawn generation settings yes there is alot here i know just ignore the long tables at the end (they were generated with a command) -- @config Spawn-Area -return { +local Features = require("modules/exp_scenario/features") + +return Features.config("spawn_area", { spawn_area = { --- @setting spawn_area Settings relating to the whole spawn area -- Enable predefined patches: 128, else: 32 deconstruction_radius = 20, -- @setting deconstruction_radius All entities within this radius will be removed @@ -12,7 +14,6 @@ return { turrets = { --- @setting turrets Settings relating to adding turrets to spawn enabled = true, --- @setting enabled Whether turrets will be added to spawn ammo_type = "uranium-rounds-magazine", --- @setting ammo_type The ammo type that will be used during refills - refill_time = 60 * 60 * 5, --- @setting refill_time The time in ticks between each refill of the turrets, only change if having lag issues offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to turrets locations = { --- @setting locations The locations of all turrets, this list can change during runtime { -3, -3 }, @@ -242,7 +243,6 @@ return { resource_refill_nearby = { enabled = false, range = 128, - refill_time = 36000, resources_name = { "iron-ore", "copper-ore", @@ -252,4 +252,4 @@ return { }, amount = { 2500, 4000 }, }, -} +}) diff --git a/exp_scenario/module/control/afk_kick.lua b/exp_scenario/module/control/afk_kick.lua index 926ef446..c7f4b8e8 100644 --- a/exp_scenario/module/control/afk_kick.lua +++ b/exp_scenario/module/control/afk_kick.lua @@ -4,8 +4,16 @@ Kicks players when all players on the server are afk local Async = require("modules/exp_util/async") local Features = require("modules/exp_scenario/features") +local Roles = require("modules/exp_roles") local Storage = require("modules/exp_util/storage") -local config = require("modules/exp_scenario/config/afk_kick") +local config = Features.config("afk_kick", { + admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time + trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time + active_role_id = Features.optional("number"), --- @setting active_role_id Players with this role or higher are treated as active regardless of afk time + afk_minutes = 10, --- @setting afk_minutes The time in minutes that must pass for a player to be considered afk + kick_minutes = 30, --- @setting kick_minutes The time in minutes that must pass without any active players for all players to be kicked + trust_minutes = 600, --- @setting trust_minutes The time in minutes that a player must be online for to count as trusted +}) local ticks_per_minute = 3600 @@ -22,13 +30,21 @@ local afk_kick_player_async = game.kick_player(player, "AFK while no active players on the server") end) +--- Check if a player has a role which counts as active regardless of afk time +--- @param player LuaPlayer +--- @return boolean +local function is_active_role(player) + local role = config.active_role_id and Roles.get_role(config.active_role_id) + return role ~= nil and not Roles.get_player_highest_role(player):is_lower_than(role) +end + --- Check if there is an active player local function has_active_player() for _, player in ipairs(game.connected_players) do if player.afk_time < config.afk_minutes * ticks_per_minute or config.admin_as_active and player.admin or config.trust_as_active and player.online_time > config.trust_minutes * ticks_per_minute - or config.custom_active_check and config.custom_active_check(player) then + or is_active_role(player) then script_data.last_active = game.tick return true end @@ -80,8 +96,8 @@ return Features.guard(config, { events = { [e.on_player_joined_game] = on_player_joined_game, }, - intervals = { - update_seconds = check_afk_players, + on_nth_tick = { + [60 * 60 * 30] = check_afk_players, }, has_active_player = has_active_player, }) diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index e3d23092..a8f00392 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -21,7 +21,7 @@ local function on_console_chat(event) -- Check if the player can chat commands local commands_allowed = true if config.command_admin_only and not player.admin then commands_allowed = false end - if config.command_permission and not Roles.player_has_permission(player, config.command_permission) then commands_allowed = false end + if not Roles.player_has_permission(player, "exp_scenario.chat.commands") then commands_allowed = false end -- Check if a key word appears in the message for key_word, reply in pairs(config.messages) do diff --git a/exp_scenario/module/control/chat_popup.lua b/exp_scenario/module/control/chat_popup.lua index a858acf3..b7da1600 100644 --- a/exp_scenario/module/control/chat_popup.lua +++ b/exp_scenario/module/control/chat_popup.lua @@ -3,7 +3,8 @@ Creates flying text entities when a player sends a message in chat ]] local FlyingText = require("modules/exp_util/flying_text") -local config = require("modules.exp_legacy.config.popup_messages") +local Features = require("modules/exp_scenario/features") +local config = require("modules/exp_scenario/config/popups") local lower = string.lower local find = string.find @@ -41,8 +42,8 @@ end local e = defines.events -return { +return Features.guard(config, { events = { [e.on_console_chat] = on_console_chat, - } -} + }, +}) diff --git a/exp_scenario/module/control/damage_popups.lua b/exp_scenario/module/control/damage_popups.lua index 803f95b6..e801ae42 100644 --- a/exp_scenario/module/control/damage_popups.lua +++ b/exp_scenario/module/control/damage_popups.lua @@ -4,7 +4,8 @@ also shows player health when a player is attacked ]] local FlyingText = require("modules/exp_util/flying_text") -local config = require("modules.exp_legacy.config.popup_messages") +local Features = require("modules/exp_scenario/features") +local config = require("modules/exp_scenario/config/popups") local random = math.random local floor = math.floor @@ -44,8 +45,8 @@ end local e = defines.events -return { +return Features.guard(config, { events = { [e.on_entity_damaged] = on_entity_damaged, }, -} +}) diff --git a/exp_scenario/module/control/death_markers.lua b/exp_scenario/module/control/death_markers.lua index cbe42a65..a6d1e4a1 100644 --- a/exp_scenario/module/control/death_markers.lua +++ b/exp_scenario/module/control/death_markers.lua @@ -14,7 +14,6 @@ local config = Features.config("death_markers", { map_icon = nil, --- @setting map_icon the icon that the map marker shows; nil means no icon; format as a SingleID show_light_at_corpse = true, --- @setting show_light_at_corpse if a light should be rendered at the corpse show_line_to_corpse = true, --- @setting show_line_to_corpse if a line should be rendered from you to your corpse - check_map_tags_seconds = 300, --- @setting check_map_tags_seconds seconds between checks for missing map markers }) local map_tag_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true } @@ -149,8 +148,8 @@ end local e = defines.events return Features.guard(config, { - intervals = { - check_map_tags_seconds = check_map_tags, + on_nth_tick = { + [60 * 60 * 5] = check_map_tags, }, events = { [e.on_player_died] = on_player_died, diff --git a/exp_scenario/module/control/discord_alerts.lua b/exp_scenario/module/control/discord_alerts.lua index 6b700341..07cc7a39 100644 --- a/exp_scenario/module/control/discord_alerts.lua +++ b/exp_scenario/module/control/discord_alerts.lua @@ -4,7 +4,43 @@ Sends alert messages to our discord server when certain events are triggered local ExpUtil = require("modules/exp_util") local Colors = require("modules/exp_util/include/color") -local config = require("modules.exp_legacy.config.discord_alerts") +local Features = require("modules/exp_scenario/features") +local EntityProtection = require("modules/exp_scenario/control/protection") +local Jail = require("modules/exp_scenario/control/jail") + +local config = Features.config("discord_alerts", { + show_playtime = true, --- @setting show_playtime Add the playtime of players to alerts + entity_protection = true, --- @setting entity_protection Alert when a player repeatedly removes protected entities + player_bans = true, --- @setting player_bans Alert when a player is banned or unbanned + player_mutes = true, --- @setting player_mutes Alert when a player is muted or unmuted + player_kicks = true, --- @setting player_kicks Alert when a player is kicked + player_promotes = false, --- @setting player_promotes Alert when a player is promoted or demoted + player_jail = true, --- @setting player_jail Alert when a player is jailed or unjailed + logged_commands = { --- @setting logged_commands Alert when a player uses one of these commands + "config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat", + }, +}) + +--- Set of the logged commands, rebuilt when the config changes +local logged_commands = {} --- @type table +Features.on_apply(config, function() + logged_commands = {} + for _, command in ipairs(config.logged_commands) do + logged_commands[command] = true + end +end) + +--- Wrap an event handler so it only runs while the alert is enabled +--- @param setting string +--- @param handler fun(event: table) +--- @return fun(event: table) +local function when(setting, handler) + return function(event) + if config[setting] then + return handler(event) + end + end +end local format_string = string.format local write_json = ExpUtil.write_json @@ -80,172 +116,158 @@ local function emit_event(opts) end --- Repeated protected entity mining -if config.entity_protection then - local EntityProtection = require("modules/exp_scenario/control/protection") - events[EntityProtection.on_repeat_violation] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Entity Protection", - description = "A player removed protected entities", - color = Colors.yellow, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "Entity", inline = true, value = event.entity.name }, - { name = "Location", value = format_string("X %.1f Y %.1f", event.entity.position.x, event.entity.position.y) }, - }, - } - end -end +events[EntityProtection.on_repeat_violation] = when("entity_protection", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Entity Protection", + description = "A player removed protected entities", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "Entity", inline = true, value = event.entity.name }, + { name = "Location", value = format_string("X %.1f Y %.1f", event.entity.position.x, event.entity.position.y) }, + }, + } +end) --- When a player is jailed or unjailed -if config.player_jail then - local Jail = require("modules/exp_scenario/control/jail") - events[Jail.on_player_jailed] = function(event) - local player_name, by_player_name = get_player_name(event) +events[Jail.on_player_jailed] = when("player_jail", function(event) + local player_name, by_player_name = get_player_name(event) + emit_event{ + title = "Jail", + description = "A player has been jailed", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Reason", value = event.reason }, + }, + } +end) +events[Jail.on_player_unjailed] = when("player_jail", function(event) + local player_name, by_player_name = get_player_name(event) + emit_event{ + title = "Unjail", + description = "A player has been unjailed", + color = Colors.green, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "By", inline = true, value = append_playtime(by_player_name) }, + }, + } +end) + +--- Ban and unban +--- @param event EventData.on_player_banned +events[e.on_player_banned] = when("player_bans", function(event) + if event.by_player then + local by_player = game.players[event.by_player] emit_event{ - title = "Jail", - description = "A player has been jailed", - color = Colors.yellow, + title = "Banned", + description = "A player has been banned", + color = Colors.red, fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Player", inline = true, value = append_playtime(event.player_name) }, + { name = "By", inline = true, value = append_playtime(by_player.name) }, { name = "Reason", value = event.reason }, }, } end - events[Jail.on_player_unjailed] = function(event) - local player_name, by_player_name = get_player_name(event) +end) +--- @param event EventData.on_player_unbanned +events[e.on_player_unbanned] = when("player_bans", function(event) + if event.by_player then + local by_player = game.players[event.by_player] emit_event{ - title = "Unjail", - description = "A player has been unjailed", + title = "Un-Banned", + description = "A player has been un-banned", color = Colors.green, fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Player", inline = true, value = append_playtime(event.player_name) }, + { name = "By", inline = true, value = append_playtime(by_player.name) }, + { name = "Reason", value = event.reason }, }, } end -end - ---- Ban and unban -if config.player_bans then - --- @param event EventData.on_player_banned - events[e.on_player_banned] = function(event) - if event.by_player then - local by_player = game.players[event.by_player] - emit_event{ - title = "Banned", - description = "A player has been banned", - color = Colors.red, - fields = { - { name = "Player", inline = true, value = append_playtime(event.player_name) }, - { name = "By", inline = true, value = append_playtime(by_player.name) }, - { name = "Reason", value = event.reason }, - }, - } - end - end - --- @param event EventData.on_player_unbanned - events[e.on_player_unbanned] = function(event) - if event.by_player then - local by_player = game.players[event.by_player] - emit_event{ - title = "Un-Banned", - description = "A player has been un-banned", - color = Colors.green, - fields = { - { name = "Player", inline = true, value = append_playtime(event.player_name) }, - { name = "By", inline = true, value = append_playtime(by_player.name) }, - { name = "Reason", value = event.reason }, - }, - } - end - end -end +end) --- Mute and unmute -if config.player_mutes then - --- @param event EventData.on_player_muted - events[e.on_player_muted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Muted", - description = "A player has been muted", - color = Colors.yellow, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end - --- @param event EventData.on_player_unmuted - events[e.on_player_unmuted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Un-Muted", - description = "A player has been un-muted", - color = Colors.green, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end -end +--- @param event EventData.on_player_muted +events[e.on_player_muted] = when("player_mutes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Muted", + description = "A player has been muted", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) +--- @param event EventData.on_player_unmuted +events[e.on_player_unmuted] = when("player_mutes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Un-Muted", + description = "A player has been un-muted", + color = Colors.green, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) --- Kick -if config.player_kicks then - --- @param event EventData.on_player_kicked - events[e.on_player_kicked] = function(event) - if event.by_player then - local player_name = get_player_name(event) - local by_player = game.players[event.by_player] - emit_event{ - title = "Kick", - description = "A player has been kicked", - color = Colors.orange, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(by_player.name) }, - { name = "Reason", value = event.reason }, - }, - } - end +--- @param event EventData.on_player_kicked +events[e.on_player_kicked] = when("player_kicks", function(event) + if event.by_player then + local player_name = get_player_name(event) + local by_player = game.players[event.by_player] + emit_event{ + title = "Kick", + description = "A player has been kicked", + color = Colors.orange, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "By", inline = true, value = append_playtime(by_player.name) }, + { name = "Reason", value = event.reason }, + }, + } end -end +end) --- Promote and demote -if config.player_promotes then - --- @param event EventData.on_player_promoted - events[e.on_player_promoted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Promote", - description = "A player has been promoted", - color = Colors.green, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end - --- @param event EventData.on_player_demoted - events[e.on_player_demoted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Demote", - description = "A player has been demoted", - color = Colors.yellow, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end -end +--- @param event EventData.on_player_promoted +events[e.on_player_promoted] = when("player_promotes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Promote", + description = "A player has been promoted", + color = Colors.green, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) +--- @param event EventData.on_player_demoted +events[e.on_player_demoted] = when("player_promotes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Demote", + description = "A player has been demoted", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) --- @param event EventData.on_console_command events[e.on_console_command] = function(event) if event.player_index then local player_name = get_player_name(event) - if config[event.command] then + if logged_commands[event.command] then emit_event{ title = event.command:gsub("^%l", string.upper), description = "/" .. event.command .. " was used", @@ -259,6 +281,6 @@ events[e.on_console_command] = function(event) end end -return { +return Features.guard(config, { events = events, -} +}) diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index ba8f4320..2117fa70 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -3,16 +3,37 @@ Disable new players from having certain items in their inventory, most commonly ]] local ExpUtil = require("modules/exp_util") +local Features = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.nukeprotect") + +local config = Features.config("nuke_protection", { + ignore_admins = true, --- @setting ignore_admins Admins can hold banned items + banned_items = { "atomic-bomb" }, --- @setting banned_items Items which are removed from the inventory of players without the bypass permission +}) + +--- Set of the banned items, rebuilt when the config changes +local banned_items = {} --- @type table +Features.on_apply(config, function() + banned_items = {} + for _, item_name in ipairs(config.banned_items) do + banned_items[item_name] = true + end +end) + +--- The inventories which are checked and the events which trigger the check +local inventories = { + [defines.events.on_player_ammo_inventory_changed] = defines.inventory.character_ammo, + [defines.events.on_player_armor_inventory_changed] = defines.inventory.character_armor, + [defines.events.on_player_gun_inventory_changed] = defines.inventory.character_guns, + [defines.events.on_player_main_inventory_changed] = defines.inventory.character_main, +} --- Check all items in the given inventory --- @param player LuaPlayer --- @param type defines.inventory ---- @param banned_items string[] -local function check_items(player, type, banned_items) +local function check_items(player, type) -- If the player has perms to be ignored, then they should be - if config.ignore_permission and Roles.player_has_permission(player, config.ignore_permission) then return end + if Roles.player_has_permission(player, "exp_scenario.bypass.nuke_protection") then return end if config.ignore_admins and player.admin then return end local items = {} --- @type LuaItemStack[] @@ -37,20 +58,16 @@ end --- Add event handlers for the different inventories local events = {} -for index, inventory in ipairs(config.inventories) do - if next(inventory.items) then - local assert_msg = "invalid event, no player index, index: " .. index - --- @param event { player_index: number } - events[inventory.event] = function(event) - local player_index = assert(event.player_index, assert_msg) - local player = assert(game.get_player(player_index)) - if player and player.valid then - check_items(player, inventory.inventory, inventory.items) - end +for event_id, inventory in pairs(inventories) do + --- @param event { player_index: number } + events[event_id] = function(event) + local player = assert(game.get_player(event.player_index)) + if player.valid then + check_items(player, inventory) end end end -return { +return Features.guard(config, { events = events, -} +}) diff --git a/exp_scenario/module/control/protection.lua b/exp_scenario/module/control/protection.lua index 9839290f..aa8d358e 100644 --- a/exp_scenario/module/control/protection.lua +++ b/exp_scenario/module/control/protection.lua @@ -3,8 +3,22 @@ Protects entities and areas from being mined by players other than the one who p ]] local Storage = require("modules/exp_util/storage") +local Features = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.protection") + +local config = Features.config("protection", { + ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter + repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_minutes in order to trigger repeated removal protection + repeat_minutes = 20, --- @setting repeat_minutes The length of time, in minutes, that protected removals will be remembered for + always_protected_names = {}, --- @setting always_protected_names Names of entities which are always protected + always_protected_types = { --- @setting always_protected_types Types of entities which are always protected + "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", + }, + always_trigger_repeat_names = {}, --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection + always_trigger_repeat_types = { --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection + "reactor", "fusion-reactor", "rocket-silo", + }, +}) local format_string = string.format local floor = math.floor @@ -19,10 +33,10 @@ local Protection = { on_repeat_violation = script.generate_event_name(), --- Names of entities which are always protected --- @type string[] - protected_entity_names = config.always_protected_names, + protected_entity_names = {}, --- Types of entities which are always protected --- @type string[] - protected_entity_types = config.always_protected_types, + protected_entity_types = {}, --- @package events = {}, --- @package @@ -46,10 +60,18 @@ local function to_set(values) return set end -local always_protected_names = to_set(config.always_protected_names) -local always_protected_types = to_set(config.always_protected_types) -local always_trigger_repeat_names = to_set(config.always_trigger_repeat_names) -local always_trigger_repeat_types = to_set(config.always_trigger_repeat_types) +local always_protected_names = {} --- @type table +local always_protected_types = {} --- @type table +local always_trigger_repeat_names = {} --- @type table +local always_trigger_repeat_types = {} --- @type table +Features.on_apply(config, function() + Protection.protected_entity_names = config.always_protected_names + Protection.protected_entity_types = config.always_protected_types + always_protected_names = to_set(config.always_protected_names) + always_protected_types = to_set(config.always_protected_types) + always_trigger_repeat_names = to_set(config.always_trigger_repeat_names) + always_trigger_repeat_types = to_set(config.always_trigger_repeat_types) +end) local protected_entities = {} --- @type table> Keyed by surface index then entity key local protected_areas = {} --- @type table> Keyed by surface index then area key @@ -168,7 +190,7 @@ end local function is_ignored(player, entity) if config.ignore_admins and player.admin then return true end if entity.last_user == nil or entity.last_user.index == player.index then return true end - if config.ignore_permission and Roles.player_has_permission(player, config.ignore_permission) then return true end + if Roles.player_has_permission(player, "exp_scenario.bypass.entity_protection") then return true end return false end @@ -199,8 +221,10 @@ end local function on_pre_player_mined_item(event) local entity = event.entity local player = game.players[event.player_index] + -- Protected entities are still forgotten while disabled, only the violations stop if - not is_ignored(player, entity) + config.enabled + and not is_ignored(player, entity) and (Protection.is_entity_protected(entity) or Protection.is_position_protected(entity.surface, entity.position)) then raise_violation(event, player) @@ -217,7 +241,7 @@ end --- Forget protected removals older than the repeat lifetime local function clear_old_repeats() - local old = game.tick - config.repeat_lifetime + local old = game.tick - config.repeat_minutes * 3600 for player_name, player_repeats in pairs(repeats) do if player_repeats.last <= old then repeats[player_name] = nil @@ -232,6 +256,6 @@ Protection.events[e.on_space_platform_pre_mined] = on_entity_removed Protection.events[e.on_robot_pre_mined] = on_entity_removed Protection.events[e.on_entity_died] = on_entity_removed Protection.events[e.script_raised_destroy] = on_entity_removed -Protection.on_nth_tick[config.refresh_rate] = clear_old_repeats +Protection.on_nth_tick[3600 * 5] = clear_old_repeats return Protection diff --git a/exp_scenario/module/control/spawn_area.lua b/exp_scenario/module/control/spawn_area.lua index d4c23275..3eba435c 100644 --- a/exp_scenario/module/control/spawn_area.lua +++ b/exp_scenario/module/control/spawn_area.lua @@ -2,7 +2,8 @@ Adds a custom spawn area with chests and afk turrets ]] -local config = require("modules.exp_legacy.config.spawn_area") +local Features = require("modules/exp_scenario/features") +local config = require("modules/exp_scenario/config/spawn_area") --- Apply an offset to a LuaPosition --- @param position MapPosition.struct @@ -251,30 +252,26 @@ end local on_nth_tick = {} -if config.turrets.enabled then - --- Refill the ammo in the spawn turrets - on_nth_tick[config.turrets.refill_time] = function() - if game.tick < 10 then return end - update_turrets() - end +--- Refill the ammo in the spawn turrets +on_nth_tick[60 * 60 * 5] = function() + if game.tick < 10 or not config.turrets.enabled then return end + update_turrets() end -if config.resource_refill_nearby.enabled then - --- - on_nth_tick[config.resource_refill_nearby.refill_time] = function() - if game.tick < 10 then return end +--- Refill the resources near spawn +on_nth_tick[60 * 60 * 10] = function() + if game.tick < 10 or not config.resource_refill_nearby.enabled then return end - local force = game.forces.player - local surface = assert(game.get_surface("nauvis")) - local entities = surface.find_entities_filtered{ - position = force.get_spawn_position(surface), - radius = config.resource_refill_nearby.range, - name = config.resource_refill_nearby.resources_name - } + local force = game.forces.player + local surface = assert(game.get_surface("nauvis")) + local entities = surface.find_entities_filtered{ + position = force.get_spawn_position(surface), + radius = config.resource_refill_nearby.range, + name = config.resource_refill_nearby.resources_name + } - for _, ore in ipairs(entities) do - ore.amount = ore.amount + math.random(config.resource_refill_nearby.amount[1], config.resource_refill_nearby.amount[2]) - end + for _, ore in ipairs(entities) do + ore.amount = ore.amount + math.random(config.resource_refill_nearby.amount[1], config.resource_refill_nearby.amount[2]) end end @@ -302,9 +299,9 @@ end local e = defines.events -return { +return Features.guard(config, { on_nth_tick = on_nth_tick, events = { [e.on_player_created] = on_player_created, - } -} + }, +}) diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua index 31927a56..081d955a 100644 --- a/exp_scenario/module/features.lua +++ b/exp_scenario/module/features.lua @@ -4,23 +4,23 @@ Features which the controller can enable, disable, and configure while the game A feature's config is the table returned by Features.config. The controller sends overrides over rcon, these are kept in storage and copied onto that same table, so read values when they are used rather than copying them into locals when the -file loads. Only boolean, number, and string defaults can be overridden. +file loads. Values derived from the config can be rebuilt with Features.on_apply. -Values used while the file loads can not change without a restart. Periods can -use intervals instead of on_nth_tick, everything else such as toolbar sprites -stays as a plain value and is not listed in features.ts. +Booleans, numbers, strings, and lists of strings can be overridden, including +those in nested tables which are addressed as "section.key". Use Features.optional +for a value which has no default. Values used while the file loads, such as +on_nth_tick periods, stay as plain values and are not listed in features.ts. --- Declaring a feature with config, small configs live at the top of the file which uses them: local Features = require("modules/exp_scenario/features") local config = Features.config("my_feature", { show_message = true, - check_seconds = 60, + role_id = Features.optional("number"), }) --- Handlers do nothing while the feature is disabled, a feature without config can be guarded by name: return Features.guard(config, { events = { [defines.events.on_player_joined_game] = on_player_joined_game }, - intervals = { check_seconds = check_players }, -- Runs every config.check_seconds seconds }) --- Toolbar buttons are hidden while disabled, which also hides their left element: @@ -42,40 +42,78 @@ local Features = { }, } -local overridable = { - boolean = true, - number = true, - string = true, -} - --- @class ExpScenario.FeatureConfig --- @field enabled boolean --- @alias ExpScenario.Feature string | ExpScenario.FeatureConfig +--- @class ExpScenario.FeatureField +--- @field parent table The table holding the value within the config +--- @field key string The key of the value within its parent +--- @field default any +--- @field type string "boolean", "number", "string", "list" of strings, or a type which can not be overridden + +--- @class ExpScenario.Optional +--- @field __feature_optional string + --- The live config tables by feature name local features = {} --- @type table ---- Copies of the defaults by feature name, used to undo overrides -local defaults = {} --- @type table> +--- The values of each feature by feature name then path +local fields = {} --- @type table> ---- Overrides received from the controller by feature name -local overrides = {} --- @type table> +--- Called after a feature is applied by feature name +local on_apply = {} --- @type table + +--- Overrides received from the controller by feature name then path +local overrides = {} --- @type table> + +--- Check if a table is a list, empty tables count as lists +--- @param tbl table +--- @return boolean +local function is_list(tbl) + return next(tbl) == nil or tbl[1] ~= nil +end + +--- Check if a table is a list of strings, empty tables count as lists of strings +--- @param tbl table +--- @return boolean +local function is_string_list(tbl) + if not is_list(tbl) then return false end + for _, item in pairs(tbl) do + if type(item) ~= "string" then return false end + end + return true +end + +--- Check if an override can replace a field +--- @param field ExpScenario.FeatureField +--- @param value any +--- @return boolean +local function is_valid(field, value) + if field.type == "list" then + return type(value) == "table" and is_string_list(value) + end + return type(value) == field.type and (field.type == "boolean" or field.type == "number" or field.type == "string") +end --- Reset a feature to its defaults and apply its overrides --- @param name string local function apply(name) - local config = features[name] - local feature_defaults = defaults[name] - for key, value in pairs(feature_defaults) do - config[key] = value + local feature_overrides = overrides[name] or {} + for path, field in pairs(fields[name]) do + local value = feature_overrides[path] + if value == nil then + value = field.default + elseif not is_valid(field, value) then + log("[WARNING] Invalid override for " .. name .. "." .. path .. ", using the default") + value = field.default + end + field.parent[field.key] = value end - for key, value in pairs(overrides[name] or {}) do - local default = feature_defaults[key] - if default ~= nil and type(value) == type(default) and overridable[type(value)] then - config[key] = value - end + for _, callback in ipairs(on_apply[name]) do + callback(features[name]) end end @@ -86,6 +124,42 @@ Storage.register(overrides, function(tbl) end end) +--- Mark a value as having no default, the argument is the type it can be overridden with +--- @param value_type "boolean" | "number" | "string" | "list" +--- @return any +function Features.optional(value_type) + return { __feature_optional = value_type } +end + +--- Collect the values of a config table, recursing into nested tables which are not lists +--- @param feature_fields table +--- @param tbl table +--- @param prefix string +local function collect_fields(feature_fields, tbl, prefix) + for key, value in pairs(tbl) do + if type(key) == "string" then + local field = { parent = tbl, key = key, default = value, type = type(value) } + if type(value) == "table" then + if value.__feature_optional then + field.default = nil + field.type = value.__feature_optional + tbl[key] = nil + elseif is_string_list(value) then + field.type = "list" + elseif is_list(value) then + field.type = "table" + else + collect_fields(feature_fields, value, prefix .. key .. ".") + field = nil + end + end + if field then + feature_fields[prefix .. key] = field + end + end + end +end + --- Declare a feature and its config, features are enabled unless the config says otherwise --- @generic T : table --- @param name string @@ -98,13 +172,12 @@ function Features.config(name, config) config.enabled = true end - local feature_defaults = {} - for key, value in pairs(config) do - feature_defaults[key] = value - end + local feature_fields = {} + collect_fields(feature_fields, config, "") features[name] = config - defaults[name] = feature_defaults + fields[name] = feature_fields + on_apply[name] = {} return config end @@ -118,6 +191,21 @@ local function resolve(feature) return features[feature] or error("Unknown feature: " .. tostring(feature), 3) end +--- Get the name of a feature from its config, or return the name given +--- @param feature ExpScenario.Feature +--- @return string +local function resolve_name(feature) + if type(feature) == "string" then + return feature + end + for name, config in pairs(features) do + if config == feature then + return name + end + end + error("Unknown feature config", 3) +end + --- Check if a feature is enabled --- @param feature ExpScenario.Feature --- @return boolean @@ -125,9 +213,18 @@ function Features.is_enabled(feature) return resolve(feature).enabled end ---- Make the handlers of an event handler lib do nothing while the feature is disabled --- A name which has not been declared is declared without config. The intervals field --- maps config keys to handlers, each runs every config[key] seconds, checked once a second +--- Call a function now and whenever the config of a feature changes, used to rebuild values derived from it +-- Also called during on_load, so it must only change locals and not the game state +--- @param feature ExpScenario.Feature +--- @param callback fun(config: any) +function Features.on_apply(feature, callback) + local name = resolve_name(feature) + table.insert(on_apply[name], callback) + callback(features[name]) +end + +--- Make the event and on_nth_tick handlers of an event handler lib do nothing while the feature is disabled +-- A name which has not been declared is declared without config --- @generic T : table --- @param feature ExpScenario.Feature --- @param lib T @@ -138,25 +235,6 @@ function Features.guard(feature, lib) end local config = resolve(feature) - local intervals = lib.intervals - if intervals then - lib.intervals = nil - lib.on_nth_tick = lib.on_nth_tick or {} - local every_second = lib.on_nth_tick[60] - lib.on_nth_tick[60] = function(event) - if every_second then - every_second(event) - end - -- Exactly one check each period lands within its first second - local tick = event.tick - for key, handler in pairs(intervals) do - if tick % (config[key] * 60) < 60 then - handler(event) - end - end - end - end - for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do for key, handler in pairs(handlers) do handlers[key] = function(event) @@ -173,17 +251,21 @@ end --- @class ExpScenario.FeatureUpdate --- @field name string --- @field enabled boolean ---- @field values table +--- @field values table --- Called over rcon by the instance plugin with the features which changed, unknown features are ignored --- @param updates ExpScenario.FeatureUpdate[] function Features.receive_update(updates) for _, update in ipairs(updates) do local name = update.name - if features[name] then + local feature_fields = fields[name] + if feature_fields then local override = { enabled = update.enabled } - for key, value in pairs(update.values) do - override[key] = value + for path, value in pairs(update.values) do + if feature_fields[path] == nil then + log("[WARNING] Unknown override for " .. name .. "." .. path) + end + override[path] = value end overrides[name] = override diff --git a/exp_scenario/test/features.test.js b/exp_scenario/test/features.test.js index c33c8049..2e3bf640 100644 --- a/exp_scenario/test/features.test.js +++ b/exp_scenario/test/features.test.js @@ -16,10 +16,35 @@ function luaFiles(dir, prefix = "") { const moduleFiles = luaFiles(path.join(import.meta.dirname, "..", "module")); -/** Read a literal default from a Features.config call, only covers single line booleans, numbers, and strings. */ -function luaDefault(source, key) { - const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"),`, "m").exec(source); - return match ? JSON.parse(match[1]) : undefined; +/** + * Read a default from a Features.config call, a path of "a.b" looks for b after the table a starts. + * Only covers booleans, numbers, strings, lists of strings, and Features.optional. + */ +function luaDefault(source, path) { + const keys = path.split("."); + let start = 0; + for (const section of keys.slice(0, -1)) { + const match = new RegExp(`^\\s*${section} = \\{`, "m").exec(source.slice(start)); + if (!match) { + return undefined; + } + start += match.index + match[0].length; + } + + const key = keys.at(-1); + const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Features\\.optional\\("\\w+"\\)|\\{[^{}]*\\})`, "m") + .exec(source.slice(start)); + if (!match) { + return undefined; + } + const literal = match[1]; + if (literal.startsWith("Features.optional")) { + return null; + } + if (literal.startsWith("{")) { + return [...literal.matchAll(/"([^"]*)"/g)].map(item => item[1]); + } + return JSON.parse(literal); } t.test("features", t2 => { @@ -31,7 +56,7 @@ t.test("features", t2 => { t3.equal(declarations.length, 1, "one lua file declares the feature"); const source = declarations[0]?.[1] ?? ""; for (const field of feature.fields) { - t3.equal(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`); + t3.strictSame(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`); } t3.end(); }); @@ -62,5 +87,21 @@ t.test("validateFeatureValues()", t2 => { () => validateFeatureValues("afk_kick", { afk_minutes: Infinity }), { message: "Setting afk_minutes of afk_kick must be a finite number" }, ); + t2.throws( + () => validateFeatureValues("protection", { always_protected_types: ["boiler", 1] }), + { message: "Setting always_protected_types of protection must be a list of strings" }, + ); + t2.throws( + () => validateFeatureValues("afk_kick", { active_role_id: 1.5 }), + { message: "Setting active_role_id of afk_kick must be a role id or null" }, + ); + t2.same( + validateFeatureValues("protection", { always_protected_types: ["boiler"], always_trigger_repeat_types: ["reactor", "fusion-reactor", "rocket-silo"] }), + { always_protected_types: ["boiler"] }, + "lists equal to their default are dropped", + ); + t2.same(validateFeatureValues("afk_kick", { active_role_id: null }), {}, "no role is the default"); + t2.same(validateFeatureValues("afk_kick", { active_role_id: 3 }), { active_role_id: 3 }); + t2.same(validateFeatureValues("spawn_area", { "turrets.enabled": false }), { "turrets.enabled": false }, "nested settings"); t2.end(); }); diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua index b46a4e34..75d17960 100644 --- a/exp_scenario/test/module/env.lua +++ b/exp_scenario/test/module/env.lua @@ -17,10 +17,13 @@ local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type --- @field storage table The table registered with storage, as on_init would store it --- @field on_load fun(tbl: table) Act out on_load with the given storage table --- @field load_config fun(name: string): table Load module/config/.lua +--- @field logged string[] Messages passed to log return Framework.suite(function(env) --- @cast env ExpScenario.TestEnv local callback --- @type fun(tbl: table) + env.logged = {} + log = function(message) env.logged[#env.logged + 1] = message end env.extend_requires{ ["modules/exp_util/storage"] = { register = function(tbl, fn) diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua index 58de21b1..2c195d6d 100644 --- a/exp_scenario/test/module/features.lua +++ b/exp_scenario/test/module/features.lua @@ -81,35 +81,57 @@ Suite.test("guard() declares features by name", function(env) Suite.eq(env.Features.is_enabled("test"), false, "and can be looked up") end) -Suite.test("guard() runs intervals once per period of the config value in seconds", function(env) - local config = env.Features.config("test", { every = 5 }) - local ticks, seconds = {}, {} - local lib = env.Features.guard(config, { - on_nth_tick = { [60] = function(event) seconds[#seconds + 1] = event.tick end }, - intervals = { every = function(event) ticks[#ticks + 1] = event.tick end }, - }) - Suite.eq(lib.intervals, nil, "intervals are replaced by on_nth_tick") +Suite.test("receive_update() logs invalid and unknown overrides", function(env) + local config = env.Features.config("test", { flag = true }) + env.Features.receive_update{ { name = "test", enabled = true, values = { flag = 1, extra = 2 } } } + Suite.eq(config.flag, true, "the default is used") + Suite.eq(#env.logged, 2, "both are logged") +end) - local function run_until(last) - for tick = 60, last, 60 do lib.on_nth_tick[60]{ tick = tick } end - end +Suite.test("config() addresses nested values by path", function(env) + local offset = { 1, 2 } + local config = env.Features.config("test", { section = { flag = true, count = 1 }, offset = offset }) + env.Features.receive_update{ { name = "test", enabled = true, values = { ["section.flag"] = false, offset = { "x" } } } } + Suite.eq(config.section, { flag = false, count = 1 }, "the nested value changes in place") + Suite.eq(config.offset, offset, "lists of other types can not be overridden") - run_until(1200) - Suite.eq(ticks, { 300, 600, 900, 1200 }, "every 5 seconds") - Suite.eq(#seconds, 20, "an existing handler every second still runs") + env.Features.receive_update{ { name = "test", enabled = true, values = {} } } + Suite.eq(config.section.flag, true, "and goes back to its default") +end) - ticks = {} - env.Features.receive_update{ { name = "test", enabled = true, values = { every = 7 } } } - run_until(1260) - Suite.eq(ticks, { 420, 840, 1260 }, "the period can change at runtime") +Suite.test("config() allows lists of strings to be overridden", function(env) + local config = env.Features.config("test", { names = { "a", "b" }, empty = {} }) + env.Features.receive_update{ { name = "test", enabled = true, values = { names = { "c" }, empty = { "d" } } } } + Suite.eq(config.names, { "c" }, "a list is replaced") + Suite.eq(config.empty, { "d" }, "an empty default is a list") + env.Features.receive_update{ { name = "test", enabled = true, values = { names = { 1 } } } } + Suite.eq(config.names, { "a", "b" }, "a list with other types is invalid") +end) + +Suite.test("optional() declares a value without a default", function(env) + local config = env.Features.config("test", { role_id = env.Features.optional("number") }) + Suite.eq(config.role_id, nil, "nil until overridden") + env.Features.receive_update{ { name = "test", enabled = true, values = { role_id = 3 } } } + Suite.eq(config.role_id, 3, "overridden with the given type") + env.Features.receive_update{ { name = "test", enabled = true, values = {} } } + Suite.eq(config.role_id, nil, "and back to nil") +end) + +Suite.test("on_apply() is called now and after every change", function(env) + local config = env.Features.config("test", { names = { "a" } }) + local seen = {} + env.Features.on_apply("test", function(applied) seen[#seen + 1] = applied.names[1] end) + env.Features.receive_update{ { name = "test", enabled = true, values = { names = { "b" } } } } + env.on_load{ test = { enabled = true, names = { "c" } } } + Suite.eq(seen, { "a", "b", "c" }, "on register, update, and load") + Suite.eq(config.names, { "c" }, "after the config is applied") end) Suite.test("config files declare their feature", function(env) - env.extend_requires{ ["modules/exp_roles"] = {} } - local config = env.load_config("afk_kick") - env.Features.receive_update{ { name = "afk_kick", enabled = true, values = { afk_minutes = 3 } } } - Suite.eq(config.afk_minutes, 3, "afk_kick can be changed") - Suite.eq(config.kick_minutes, 30, "other values keep their default") + local config = env.load_config("popups") + env.Features.receive_update{ { name = "popups", enabled = true, values = { show_player_damage = false } } } + Suite.eq(config.show_player_damage, false, "popups can be changed") + Suite.eq(config.show_player_health, true, "other values keep their default") end) return Suite.run() diff --git a/exp_scenario/web/components/FeaturesPage.tsx b/exp_scenario/web/components/FeaturesPage.tsx index 7e134615..59f961a5 100644 --- a/exp_scenario/web/components/FeaturesPage.tsx +++ b/exp_scenario/web/components/FeaturesPage.tsx @@ -1,146 +1,323 @@ import React, { useContext, useState } from "react"; -import { Button, Card, Col, Empty, Form, Input, InputNumber, Row, Space, Switch, Typography, theme } from "antd"; +import { Button, Checkbox, Col, Divider, Input, InputNumber, Row, Select, Space, Switch } from "antd"; -import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount } from "@clusterio/web_ui"; +import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount, useRoles } from "@clusterio/web_ui"; -import { Feature, FeatureField, FeatureValue, features } from "../../features.js"; +import { Feature, FeatureField, FeatureValue, features, isDefaultValue } from "../../features.js"; import { FeatureRecord, FeatureUpdateRequest } from "../../messages.js"; import { useFeatures } from "../index"; -function FeatureListItem(props: { feature: Feature, enabled: boolean, selected: boolean, onClick: () => void }) { - const { token } = theme.useToken(); +type FeatureState = { enabled: boolean, values: Record }; +type PendingChanges = Record }>; + +// Same colours as the role and permission group pages +const borderColor = "#424242"; +const modifiedColor = "#2a1912"; + +function sameValue(a: FeatureValue | undefined, b: FeatureValue | undefined) { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, index) => item === b[index]); + } + return a === b; +} + +/** The stored state of a feature, with defaults for values which were not set. */ +function storedState(feature: Feature, record: FeatureRecord | undefined): FeatureState { + return { + enabled: record?.enabled ?? true, + values: { + ...Object.fromEntries(feature.fields.map(field => [field.name, field.default])), + ...record?.values, + }, + }; +} + +/** Group fields by the section in their name, "turrets.enabled" is in the "turrets" section. */ +function groupFields(fields: FeatureField[]) { + const groups = new Map(); + for (const field of fields) { + const section = field.name.includes(".") ? field.name.slice(0, field.name.lastIndexOf(".")) : ""; + if (!groups.has(section)) { + groups.set(section, []); + } + groups.get(section)!.push(field); + } + return [...groups.entries()]; +} + +/** Converts a section name into Title Case, same as the group titles on the role page */ +function formatSectionTitle(name: string) { + return name + .replace(/\./g, " / ") + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +function matches(text: string, query: string) { + return text.toLowerCase().includes(query); +} + +/** The fields of a feature which match the search, every field if the feature itself matches. */ +function searchFields(feature: Feature, query: string) { + if (!query || matches(feature.title, query) || matches(feature.description, query) || matches(feature.name, query)) { + return feature.fields; + } + return feature.fields.filter(field => ( + matches(field.title, query) || matches(field.description, query) || matches(field.name, query) + )); +} + +function RoleSelect(props: { value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) { + const [roles, synced] = useRoles(); + return onChange(e.target.value)} style={{ maxWidth: 400 }} />; + case "string_list": + return ; - } -} - -function defaultValues(feature: Feature) { - return Object.fromEntries(feature.fields.map(field => [field.name, field.default])); -} - -function FeatureForm(props: { feature: Feature, record: FeatureRecord | undefined }) { - const { feature, record } = props; - const control = useContext(ControlContext); - const account = useAccount(); - const [form] = Form.useForm(); - const [dirty, setDirty] = useState(false); - const [saving, setSaving] = useState(false); - const canEdit = account.hasPermission("exp_scenario.config.edit"); - - const initialValues = { - enabled: record?.enabled ?? true, - ...defaultValues(feature), - ...record?.values, - }; - - function save() { - const { enabled, ...values } = form.getFieldsValue() as { enabled: boolean } & Record; - setSaving(true); - control.send(new FeatureUpdateRequest(feature.name, enabled, values)) - .then(() => setDirty(false)) - .catch(notifyErrorHandler(`Error saving ${feature.title}`)) - .finally(() => setSaving(false)); - } - - return - {feature.title} - - {feature.description} - - } - > -
setDirty(true)} - onFinish={save} - > - - - - {feature.fields.map(field => - {fieldInput(field, !canEdit)} - )} - {canEdit && - - - } -
-
; -} - /** Enable, disable, and configure the scenario features listed in features.ts. */ export default function FeaturesPage() { + const control = useContext(ControlContext); + const account = useAccount(); const [records] = useFeatures(); - const [selected, setSelected] = useState(features[0]?.name); - const feature = features.find(f => f.name === selected); - const record = selected ? records.get(selected) : undefined; + const [pending, setPending] = useState({}); + const [search, setSearch] = useState(""); + const canEdit = Boolean(account.hasPermission("exp_scenario.config.edit")); + + const stored = Object.fromEntries(features.map(feature => [feature.name, storedState(feature, records.get(feature.name))])); + const current = (name: string): FeatureState => ({ + enabled: pending[name]?.enabled ?? stored[name].enabled, + values: { ...stored[name].values, ...pending[name]?.values }, + }); + + /** Record a change, dropping it again if it matches what is stored */ + function change(name: string, update: (entry: PendingChanges[string]) => void) { + setPending(prev => { + const entry = { enabled: prev[name]?.enabled, values: { ...prev[name]?.values } }; + update(entry); + if (entry.enabled === stored[name].enabled) { + delete entry.enabled; + } + for (const [key, value] of Object.entries(entry.values)) { + if (sameValue(value, stored[name].values[key])) { + delete entry.values[key]; + } + } + const next = { ...prev }; + if (entry.enabled === undefined && !Object.keys(entry.values).length) { + delete next[name]; + } else { + next[name] = entry; + } + return next; + }); + } + + function applyChanges() { + for (const name of Object.keys(pending)) { + const state = current(name); + control.send(new FeatureUpdateRequest(name, state.enabled, state.values)) + .then(() => setPending(prev => { + const next = { ...prev }; + delete next[name]; + return next; + })) + .catch(notifyErrorHandler(`Error saving ${features.find(f => f.name === name)?.title ?? name}`)); + } + } + + const query = search.trim().toLowerCase(); + const shown = features + .map(feature => ({ feature, fields: searchFields(feature, query) })) + .filter(({ feature, fields }) => !query || fields.length || matches(feature.title, query)); return - - - - {features.map(f => setSelected(f.name)} + + {Object.keys(pending).length > 0 &&
+ You have unsaved changes + + + + +
} + + setSearch(e.target.value)} style={{ marginBottom: 16 }} /> + + + + {/* Clear of the fixed page header */} +
+ {shown.map(({ feature }) => )} - +
- - {feature - // Remount on save from anywhere so the form shows the stored values - ? - : } + + + {shown.map(({ feature, fields }) => change(feature.name, entry => { entry.enabled = enabled; })} + onValue={(key, value) => change(feature.name, entry => { entry.values[key] = value; })} + onReset={() => change(feature.name, entry => { + for (const field of feature.fields) { + entry.values[field.name] = field.default; + } + })} + />)} +
; From 628e4df514f9e7ee40a9db653e06e820fed60265 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:03:11 +0000 Subject: [PATCH 04/11] Rework features into a Feature class with per field events Review points from Cooldude: - Feature.register(name[, config]) is required again and returns a feature object. Each feature has its config, its fields, and its overrides, so one registry holds all the data. Only the overrides are stored. On load each feature points at its own stored table again. - The methods are feature:guard(lib), feature:update_config(enabled, values), and feature:is_enabled(). Feature.get(name) looks up a feature, and commands flag the feature object. - update_config raises on_config_updated once per changed value, with the feature name, path, old value, and new value. The gui refresh only reacts to the "enabled" path. - apply only sets values. on_apply is gone. Lookup sets built from lists use Feature.to_set, which is cached by the list itself. A replaced list gets a new set, so the sets are also correct after a load. Protection exposes getters for its protected names and types. Web: settings use small inputs, the same size as the config pages. Fields are shaped like clusterio config field definitions. A field with an inputComponent renders the component registered with the web interface, so the role setting now uses core's role input, and other plugin inputs can be used later. Co-Authored-By: Claude Opus 5.5 --- exp_scenario/features.ts | 37 +- exp_scenario/module/commands/_authorities.lua | 4 +- exp_scenario/module/commands/lawnmower.lua | 11 +- .../module/commands/protected_entities.lua | 10 +- .../module/config/chat_auto_reply.lua | 4 +- exp_scenario/module/config/popups.lua | 4 +- exp_scenario/module/config/spawn_area.lua | 4 +- exp_scenario/module/control/afk_kick.lua | 11 +- .../module/control/chat_auto_reply.lua | 9 +- exp_scenario/module/control/chat_popup.lua | 9 +- exp_scenario/module/control/damage_popups.lua | 9 +- exp_scenario/module/control/death_markers.lua | 9 +- .../module/control/discord_alerts.lua | 20 +- exp_scenario/module/control/features.lua | 8 +- .../module/control/mine_depletion.lua | 9 +- .../module/control/nuke_protection.lua | 19 +- exp_scenario/module/control/protection.lua | 44 +-- exp_scenario/module/control/spawn_area.lua | 9 +- .../module/control/station_auto_name.lua | 9 +- exp_scenario/module/features.lua | 347 +++++++++--------- exp_scenario/module/gui/autofill.lua | 10 +- exp_scenario/module/gui/quick_actions.lua | 8 +- exp_scenario/test/features.test.js | 14 +- exp_scenario/test/module/env.lua | 8 +- exp_scenario/test/module/features.lua | 173 ++++----- exp_scenario/web/components/FeaturesPage.tsx | 99 +++-- 26 files changed, 468 insertions(+), 430 deletions(-) diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts index fa97660b..48179a69 100644 --- a/exp_scenario/features.ts +++ b/exp_scenario/features.ts @@ -8,17 +8,24 @@ export type FeatureValue = boolean | number | string | string[] | null; +/** + * A setting of a feature, shaped like a clusterio config field definition so + * the web interface can render it with the same inputs, including the input + * components registered by plugins. + */ export type FeatureField = { name: string, title: string, description: string, + /** Allows null, the lua side uses Feature.optional */ + optional?: boolean, + /** Name of an input component registered with the web interface, such as "role" */ + inputComponent?: string, } & ( | { type: "boolean", default: boolean } - | { type: "number", default: number, min?: number, unit?: string } - | { type: "string", default: string } + | { type: "number", default: number | null, min?: number, unit?: string } + | { type: "string", default: string | null, enum?: string[] } | { type: "string_list", default: string[] } - /** Id of a clusterio role, null when no role is picked */ - | { type: "role", default: null } ); export type Feature = { @@ -44,8 +51,9 @@ function list(name: string, title: string, description: string, value: string[]) return { name, title, description, type: "string_list", default: value }; } +/** Id of a clusterio role, null when no role is picked */ function role(name: string, title: string, description: string): FeatureField { - return { name, title, description, type: "role", default: null }; + return { name, title, description, type: "number", default: null, optional: true, inputComponent: "role" }; } export const features: Feature[] = [ @@ -229,17 +237,18 @@ export function validateFeatureValues(name: string, values: Record typeof item !== "string")) { throw new Error(`${label} must be a list of strings`); } return; - case "role": - if (value !== null && !Number.isInteger(value)) { - throw new Error(`${label} must be a role id or null`); - } - return; case "number": if (typeof value !== "number" || !Number.isFinite(value)) { throw new Error(`${label} must be a finite number`); @@ -248,6 +257,14 @@ function checkFieldValue(field: FeatureField, value: FeatureValue, label: string throw new Error(`${label} must be at least ${field.min}`); } return; + case "string": + if (typeof value !== "string") { + throw new Error(`${label} must be a string`); + } + if (field.enum && !field.enum.includes(value)) { + throw new Error(`${label} must be one of ${field.enum.join(", ")}`); + } + return; default: if (typeof value !== field.type) { throw new Error(`${label} must be a ${field.type}`); diff --git a/exp_scenario/module/commands/_authorities.lua b/exp_scenario/module/commands/_authorities.lua index 7b7a4f89..7379e4e4 100644 --- a/exp_scenario/module/commands/_authorities.lua +++ b/exp_scenario/module/commands/_authorities.lua @@ -5,7 +5,7 @@ Adds a permission authority which checks the clusterio permission for a command local Commands = require("modules/exp_commands") local add, allow, deny = Commands.add_permission_authority, Commands.status.success, Commands.status.unauthorised -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local player_has_permission = Roles.player_has_permission @@ -27,7 +27,7 @@ authorities.exp_permission = authorities.feature_enabled = add(function(player, command) local feature = command.flags.feature - if feature and not Features.is_enabled(feature) then + if feature and not feature:is_enabled() then return deny{ "exp-commands-authorities_feature.deny" } else return allow() diff --git a/exp_scenario/module/commands/lawnmower.lua b/exp_scenario/module/commands/lawnmower.lua index 043825b6..ab69d9d5 100644 --- a/exp_scenario/module/commands/lawnmower.lua +++ b/exp_scenario/module/commands/lawnmower.lua @@ -4,13 +4,14 @@ Adds a command that clean up biter corpse and nuclear hole local AABB = require("modules/exp_util/aabb") local Commands = require("modules/exp_commands") -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Selection = require("modules/exp_util/selection") local SelectArea = Selection.connect("ExpCommand_Lawnmower") -local config = Features.config("lawnmower", { +local feature = Feature.register("lawnmower", { destroy_decoratives = false, --- @setting destroy_decoratives remove decoratives under entities when they are built }) +local config = feature.config --- @class ExpCommand_Lawnmower.commands local commands = {} @@ -19,7 +20,7 @@ local commands = {} --- @class ExpCommands_Lawnmower.commands.lawnmower: ExpCommand --- @overload fun(player: LuaPlayer) commands.lawnmower = Commands.new("lawnmower", { "exp-commands_lawnmower.description" }) - :add_flags{ feature = config } + :add_flags{ feature = feature } :register(function(player) if SelectArea:stop(player) then return Commands.status.success{ "exp_util.selection_exit", { "exp-commands_lawnmower.selection-name" } } @@ -65,7 +66,7 @@ end local e = defines.events -return Features.guard(config, { +return feature:guard{ events = { [e.on_built_entity] = destroy_decoratives, [e.on_robot_built_entity] = destroy_decoratives, @@ -73,4 +74,4 @@ return Features.guard(config, { [e.script_raised_revive] = destroy_decoratives, }, commands = commands, -}) +} diff --git a/exp_scenario/module/commands/protected_entities.lua b/exp_scenario/module/commands/protected_entities.lua index 34b66783..b2b14c9a 100644 --- a/exp_scenario/module/commands/protected_entities.lua +++ b/exp_scenario/module/commands/protected_entities.lua @@ -166,15 +166,17 @@ local function on_player_selection_start(event) end -- Show always protected entities by name - if #EntityProtection.protected_entity_names > 0 then - for _, entity in pairs(surface.find_entities_filtered{ name = EntityProtection.protected_entity_names, force = player.force }) do + local protected_entity_names = EntityProtection.get_protected_entity_names() + if #protected_entity_names > 0 then + for _, entity in pairs(surface.find_entities_filtered{ name = protected_entity_names, force = player.force }) do show_protected_entity(player, entity) end end -- Show always protected entities by type - if #EntityProtection.protected_entity_types > 0 then - for _, entity in pairs(surface.find_entities_filtered{ type = EntityProtection.protected_entity_types, force = player.force }) do + local protected_entity_types = EntityProtection.get_protected_entity_types() + if #protected_entity_types > 0 then + for _, entity in pairs(surface.find_entities_filtered{ type = protected_entity_types, force = player.force }) do show_protected_entity(player, entity) end end diff --git a/exp_scenario/module/config/chat_auto_reply.lua b/exp_scenario/module/config/chat_auto_reply.lua index 344d0fc2..718402d6 100644 --- a/exp_scenario/module/config/chat_auto_reply.lua +++ b/exp_scenario/module/config/chat_auto_reply.lua @@ -2,7 +2,7 @@ -- @config Chat-Auto-Reply local ExpUtil = require("modules/exp_util") -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Async = require("modules/exp_util/async") local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail @@ -25,7 +25,7 @@ local afk_time_units = { seconds = true, } -return Features.config("chat_auto_reply", { +return Feature.register("chat_auto_reply", { --- @type table messages = { --- @setting messages will trigger when ever the word is said ["discord"] = { "info.discord", config_server_detail["discord"] }, diff --git a/exp_scenario/module/config/popups.lua b/exp_scenario/module/config/popups.lua index 60471cad..1ad77145 100644 --- a/exp_scenario/module/config/popups.lua +++ b/exp_scenario/module/config/popups.lua @@ -1,9 +1,9 @@ --- Popups for chat and damage, shared by control/chat_popup.lua and control/damage_popups.lua -- @config Popups -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") -return Features.config("popups", { +return Feature.register("popups", { show_player_messages = true, --- @setting show_player_messages weather a message in chat will make a popup above them show_player_mentions = true, --- @setting show_player_mentions weather a mentioned player will have a popup when mentioned in chat show_player_damage = true, --- @setting show_player_damage weather to show damage done by players diff --git a/exp_scenario/module/config/spawn_area.lua b/exp_scenario/module/config/spawn_area.lua index aaca027c..3093062b 100644 --- a/exp_scenario/module/config/spawn_area.lua +++ b/exp_scenario/module/config/spawn_area.lua @@ -1,9 +1,9 @@ --- Used to config the spawn generation settings yes there is alot here i know just ignore the long tables at the end (they were generated with a command) -- @config Spawn-Area -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") -return Features.config("spawn_area", { +return Feature.register("spawn_area", { spawn_area = { --- @setting spawn_area Settings relating to the whole spawn area -- Enable predefined patches: 128, else: 32 deconstruction_radius = 20, -- @setting deconstruction_radius All entities within this radius will be removed diff --git a/exp_scenario/module/control/afk_kick.lua b/exp_scenario/module/control/afk_kick.lua index c7f4b8e8..b2d82088 100644 --- a/exp_scenario/module/control/afk_kick.lua +++ b/exp_scenario/module/control/afk_kick.lua @@ -3,17 +3,18 @@ Kicks players when all players on the server are afk ]] local Async = require("modules/exp_util/async") -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local Storage = require("modules/exp_util/storage") -local config = Features.config("afk_kick", { +local feature = Feature.register("afk_kick", { admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time - active_role_id = Features.optional("number"), --- @setting active_role_id Players with this role or higher are treated as active regardless of afk time + active_role_id = Feature.optional("number"), --- @setting active_role_id Players with this role or higher are treated as active regardless of afk time afk_minutes = 10, --- @setting afk_minutes The time in minutes that must pass for a player to be considered afk kick_minutes = 30, --- @setting kick_minutes The time in minutes that must pass without any active players for all players to be kicked trust_minutes = 600, --- @setting trust_minutes The time in minutes that a player must be online for to count as trusted }) +local config = feature.config local ticks_per_minute = 3600 @@ -92,7 +93,7 @@ end local e = defines.events -return Features.guard(config, { +return feature:guard{ events = { [e.on_player_joined_game] = on_player_joined_game, }, @@ -100,4 +101,4 @@ return Features.guard(config, { [60 * 60 * 30] = check_afk_players, }, has_active_player = has_active_player, -}) +} diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index a8f00392..20f28571 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -2,9 +2,10 @@ Adds auto replies to chat messages, as well as chat commands ]] -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules/exp_scenario/config/chat_auto_reply") +local feature = require("modules/exp_scenario/config/chat_auto_reply") +local config = feature.config local find = string.find local sub = string.sub @@ -57,8 +58,8 @@ end local e = defines.events -return Features.guard(config, { +return feature:guard{ events = { [e.on_console_chat] = on_console_chat, }, -}) +} diff --git a/exp_scenario/module/control/chat_popup.lua b/exp_scenario/module/control/chat_popup.lua index b7da1600..ec01287b 100644 --- a/exp_scenario/module/control/chat_popup.lua +++ b/exp_scenario/module/control/chat_popup.lua @@ -3,8 +3,9 @@ Creates flying text entities when a player sends a message in chat ]] local FlyingText = require("modules/exp_util/flying_text") -local Features = require("modules/exp_scenario/features") -local config = require("modules/exp_scenario/config/popups") +local Feature = require("modules/exp_scenario/features") +local feature = require("modules/exp_scenario/config/popups") +local config = feature.config local lower = string.lower local find = string.find @@ -42,8 +43,8 @@ end local e = defines.events -return Features.guard(config, { +return feature:guard{ events = { [e.on_console_chat] = on_console_chat, }, -}) +} diff --git a/exp_scenario/module/control/damage_popups.lua b/exp_scenario/module/control/damage_popups.lua index e801ae42..deb40821 100644 --- a/exp_scenario/module/control/damage_popups.lua +++ b/exp_scenario/module/control/damage_popups.lua @@ -4,8 +4,9 @@ also shows player health when a player is attacked ]] local FlyingText = require("modules/exp_util/flying_text") -local Features = require("modules/exp_scenario/features") -local config = require("modules/exp_scenario/config/popups") +local Feature = require("modules/exp_scenario/features") +local feature = require("modules/exp_scenario/config/popups") +local config = feature.config local random = math.random local floor = math.floor @@ -45,8 +46,8 @@ end local e = defines.events -return Features.guard(config, { +return feature:guard{ events = { [e.on_entity_damaged] = on_entity_damaged, }, -}) +} diff --git a/exp_scenario/module/control/death_markers.lua b/exp_scenario/module/control/death_markers.lua index a6d1e4a1..41507326 100644 --- a/exp_scenario/module/control/death_markers.lua +++ b/exp_scenario/module/control/death_markers.lua @@ -3,10 +3,10 @@ Makes markers on the map where places have died and reclaims items if not recove ]] local ExpUtil = require("modules/exp_util") -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Storage = require("modules/exp_util/storage") -local config = Features.config("death_markers", { +local feature = Feature.register("death_markers", { collect_corpses = true, --- @setting collect_corpses enables items being returned to the spawn point in chests upon corpse expiring show_map_markers = true, --- @setting show_map_markers shows markers on the map where bodies are clean_map_markers = false, --- @setting clean_map_markers removes the map marker once the body is gone @@ -15,6 +15,7 @@ local config = Features.config("death_markers", { show_light_at_corpse = true, --- @setting show_light_at_corpse if a light should be rendered at the corpse show_line_to_corpse = true, --- @setting show_line_to_corpse if a line should be rendered from you to your corpse }) +local config = feature.config local map_tag_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true } @@ -147,7 +148,7 @@ end local e = defines.events -return Features.guard(config, { +return feature:guard{ on_nth_tick = { [60 * 60 * 5] = check_map_tags, }, @@ -157,4 +158,4 @@ return Features.guard(config, { [e.on_player_respawned] = on_player_respawned, [e.on_character_corpse_expired] = on_character_corpse_expired, }, -}) +} diff --git a/exp_scenario/module/control/discord_alerts.lua b/exp_scenario/module/control/discord_alerts.lua index 07cc7a39..87258e43 100644 --- a/exp_scenario/module/control/discord_alerts.lua +++ b/exp_scenario/module/control/discord_alerts.lua @@ -4,11 +4,11 @@ Sends alert messages to our discord server when certain events are triggered local ExpUtil = require("modules/exp_util") local Colors = require("modules/exp_util/include/color") -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local EntityProtection = require("modules/exp_scenario/control/protection") local Jail = require("modules/exp_scenario/control/jail") -local config = Features.config("discord_alerts", { +local feature = Feature.register("discord_alerts", { show_playtime = true, --- @setting show_playtime Add the playtime of players to alerts entity_protection = true, --- @setting entity_protection Alert when a player repeatedly removes protected entities player_bans = true, --- @setting player_bans Alert when a player is banned or unbanned @@ -20,15 +20,7 @@ local config = Features.config("discord_alerts", { "config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat", }, }) - ---- Set of the logged commands, rebuilt when the config changes -local logged_commands = {} --- @type table -Features.on_apply(config, function() - logged_commands = {} - for _, command in ipairs(config.logged_commands) do - logged_commands[command] = true - end -end) +local config = feature.config --- Wrap an event handler so it only runs while the alert is enabled --- @param setting string @@ -267,7 +259,7 @@ end) events[e.on_console_command] = function(event) if event.player_index then local player_name = get_player_name(event) - if logged_commands[event.command] then + if Feature.to_set(config.logged_commands)[event.command] then emit_event{ title = event.command:gsub("^%l", string.upper), description = "/" .. event.command .. " was used", @@ -281,6 +273,6 @@ events[e.on_console_command] = function(event) end end -return Features.guard(config, { +return feature:guard{ events = events, -}) +} diff --git a/exp_scenario/module/control/features.lua b/exp_scenario/module/control/features.lua index 49ec13da..738ddc38 100644 --- a/exp_scenario/module/control/features.lua +++ b/exp_scenario/module/control/features.lua @@ -2,11 +2,13 @@ Refreshes the gui of every player when a feature is enabled or disabled, see features.lua ]] -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Gui = require("modules/exp_gui") --- Toolbar buttons of disabled features are hidden, which also hides their left elements -local function on_feature_changed() +--- @param event EventData.ExpScenario.on_config_updated +local function on_config_updated(event) + if event.path ~= "enabled" then return end for _, player in pairs(game.connected_players) do Gui._ensure_consistency{ player_index = player.index } end @@ -14,6 +16,6 @@ end return { events = { - [Features.events.on_feature_changed] = on_feature_changed, + [Feature.events.on_config_updated] = on_config_updated, }, } diff --git a/exp_scenario/module/control/mine_depletion.lua b/exp_scenario/module/control/mine_depletion.lua index e1a1bfff..188e6b39 100644 --- a/exp_scenario/module/control/mine_depletion.lua +++ b/exp_scenario/module/control/mine_depletion.lua @@ -3,13 +3,14 @@ Marks mining drills for deconstruction when resources deplete ]] local Async = require("modules/exp_util/async") -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") -local config = Features.config("mine_depletion", { +local feature = Feature.register("mine_depletion", { fluid = true, --- @setting fluid When true, checks for for fluid pipes when removing miners chest = true, --- @setting chest When true, checks for for chest when removing miners beacon = true, --- @setting beacon When true, checks for for beacon when removing miners }) +local config = feature.config local floor = math.floor @@ -276,8 +277,8 @@ end local e = defines.events -return Features.guard(config, { +return feature:guard{ events = { [e.on_resource_depleted] = on_resource_depleted, }, -}) +} diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index 2117fa70..13210cc3 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -3,22 +3,14 @@ Disable new players from having certain items in their inventory, most commonly ]] local ExpUtil = require("modules/exp_util") -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = Features.config("nuke_protection", { +local feature = Feature.register("nuke_protection", { ignore_admins = true, --- @setting ignore_admins Admins can hold banned items banned_items = { "atomic-bomb" }, --- @setting banned_items Items which are removed from the inventory of players without the bypass permission }) - ---- Set of the banned items, rebuilt when the config changes -local banned_items = {} --- @type table -Features.on_apply(config, function() - banned_items = {} - for _, item_name in ipairs(config.banned_items) do - banned_items[item_name] = true - end -end) +local config = feature.config --- The inventories which are checked and the events which trigger the check local inventories = { @@ -36,6 +28,7 @@ local function check_items(player, type) if Roles.player_has_permission(player, "exp_scenario.bypass.nuke_protection") then return end if config.ignore_admins and player.admin then return end + local banned_items = Feature.to_set(config.banned_items) local items = {} --- @type LuaItemStack[] local inventory = assert(player.get_inventory(type)) -- Check what items the player has @@ -68,6 +61,6 @@ for event_id, inventory in pairs(inventories) do end end -return Features.guard(config, { +return feature:guard{ events = events, -}) +} diff --git a/exp_scenario/module/control/protection.lua b/exp_scenario/module/control/protection.lua index aa8d358e..d13c650c 100644 --- a/exp_scenario/module/control/protection.lua +++ b/exp_scenario/module/control/protection.lua @@ -3,10 +3,10 @@ Protects entities and areas from being mined by players other than the one who p ]] local Storage = require("modules/exp_util/storage") -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = Features.config("protection", { +local feature = Feature.register("protection", { ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_minutes in order to trigger repeated removal protection repeat_minutes = 20, --- @setting repeat_minutes The length of time, in minutes, that protected removals will be remembered for @@ -19,6 +19,7 @@ local config = Features.config("protection", { "reactor", "fusion-reactor", "rocket-silo", }, }) +local config = feature.config local format_string = string.format local floor = math.floor @@ -31,12 +32,6 @@ local Protection = { --- Raised when a player mines protected entities repeatedly, or one which always counts as repeated --- @type EventData.ExpScenario_Protection.on_repeat_violation on_repeat_violation = script.generate_event_name(), - --- Names of entities which are always protected - --- @type string[] - protected_entity_names = {}, - --- Types of entities which are always protected - --- @type string[] - protected_entity_types = {}, --- @package events = {}, --- @package @@ -50,28 +45,17 @@ local Protection = { --- @field last uint Tick of the last protected removal --- @field count number Protected removals since the last repeat violation ---- @param values string[] ---- @return table -local function to_set(values) - local set = {} - for _, value in ipairs(values) do - set[value] = true - end - return set +--- Names of entities which are always protected +--- @return string[] +function Protection.get_protected_entity_names() + return config.always_protected_names end -local always_protected_names = {} --- @type table -local always_protected_types = {} --- @type table -local always_trigger_repeat_names = {} --- @type table -local always_trigger_repeat_types = {} --- @type table -Features.on_apply(config, function() - Protection.protected_entity_names = config.always_protected_names - Protection.protected_entity_types = config.always_protected_types - always_protected_names = to_set(config.always_protected_names) - always_protected_types = to_set(config.always_protected_types) - always_trigger_repeat_names = to_set(config.always_trigger_repeat_names) - always_trigger_repeat_types = to_set(config.always_trigger_repeat_types) -end) +--- Types of entities which are always protected +--- @return string[] +function Protection.get_protected_entity_types() + return config.always_protected_types +end local protected_entities = {} --- @type table> Keyed by surface index then entity key local protected_areas = {} --- @type table> Keyed by surface index then area key @@ -131,7 +115,7 @@ end --- @param entity LuaEntity --- @return boolean function Protection.is_entity_protected(entity) - if always_protected_names[entity.name] or always_protected_types[entity.type] then return true end + if Feature.to_set(config.always_protected_names)[entity.name] or Feature.to_set(config.always_protected_types)[entity.type] then return true end local entities = protected_entities[entity.surface.index] if not entities then return false end return entities[Protection.get_entity_key(entity)] == entity @@ -209,7 +193,7 @@ local function raise_violation(event, player) script.raise_event(Protection.on_player_mined_protected, event) local entity = event.entity - local always_repeat = always_trigger_repeat_names[entity.name] or always_trigger_repeat_types[entity.type] + local always_repeat = Feature.to_set(config.always_trigger_repeat_names)[entity.name] or Feature.to_set(config.always_trigger_repeat_types)[entity.type] if always_repeat or player_repeats.count >= config.repeat_count then player_repeats.count = 0 script.raise_event(Protection.on_repeat_violation, event) diff --git a/exp_scenario/module/control/spawn_area.lua b/exp_scenario/module/control/spawn_area.lua index 3eba435c..0c18d2f8 100644 --- a/exp_scenario/module/control/spawn_area.lua +++ b/exp_scenario/module/control/spawn_area.lua @@ -2,8 +2,9 @@ Adds a custom spawn area with chests and afk turrets ]] -local Features = require("modules/exp_scenario/features") -local config = require("modules/exp_scenario/config/spawn_area") +local Feature = require("modules/exp_scenario/features") +local feature = require("modules/exp_scenario/config/spawn_area") +local config = feature.config --- Apply an offset to a LuaPosition --- @param position MapPosition.struct @@ -299,9 +300,9 @@ end local e = defines.events -return Features.guard(config, { +return feature:guard{ on_nth_tick = on_nth_tick, events = { [e.on_player_created] = on_player_created, }, -}) +} diff --git a/exp_scenario/module/control/station_auto_name.lua b/exp_scenario/module/control/station_auto_name.lua index 8d9273cc..142f4a31 100644 --- a/exp_scenario/module/control/station_auto_name.lua +++ b/exp_scenario/module/control/station_auto_name.lua @@ -2,13 +2,14 @@ Automatically name stations when they are placed based on closest resource and direction from spawn ]] -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") -local config = Features.config("station_auto_name", { +local feature = Feature.register("station_auto_name", { --- @setting station_name the name given to new stations, placeholders: -- __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__ station_name = "[L] __icon__", }) +local config = feature.config local get_direction do local directions = { @@ -104,9 +105,9 @@ end local e = defines.events -return Features.guard(config, { +return feature:guard{ events = { [e.on_built_entity] = rename_station, [e.on_robot_built_entity] = rename_station, }, -}) +} diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua index 081d955a..c9ae2f5c 100644 --- a/exp_scenario/module/features.lua +++ b/exp_scenario/module/features.lua @@ -1,141 +1,111 @@ --[[-- ExpScenario - Features Features which the controller can enable, disable, and configure while the game is running -A feature's config is the table returned by Features.config. The controller sends -overrides over rcon, these are kept in storage and copied onto that same table, +A feature's config table is changed in place when the controller sends overrides, so read values when they are used rather than copying them into locals when the -file loads. Values derived from the config can be rebuilt with Features.on_apply. +file loads. Booleans, numbers, strings, and lists of strings can be overridden, +including those in nested tables which are addressed as "section.key". Use +Feature.optional for a value which has no default. Values used while the file +loads, such as on_nth_tick periods, stay as plain values and are not listed in +features.ts. -Booleans, numbers, strings, and lists of strings can be overridden, including -those in nested tables which are addressed as "section.key". Use Features.optional -for a value which has no default. Values used while the file loads, such as -on_nth_tick periods, stay as plain values and are not listed in features.ts. - ---- Declaring a feature with config, small configs live at the top of the file which uses them: -local Features = require("modules/exp_scenario/features") -local config = Features.config("my_feature", { +--- Registering a feature, small configs live at the top of the file which uses them: +local Feature = require("modules/exp_scenario/features") +local feature = Feature.register("my_feature", { show_message = true, - role_id = Features.optional("number"), + role_id = Feature.optional("number"), }) +local config = feature.config ---- Handlers do nothing while the feature is disabled, a feature without config can be guarded by name: -return Features.guard(config, { +--- Handlers do nothing while the feature is disabled: +return feature:guard{ events = { [defines.events.on_player_joined_game] = on_player_joined_game }, -}) +} --- Toolbar buttons are hidden while disabled, which also hides their left element: Gui.toolbar.create_button{ - visible = function(player, element) return Features.is_enabled("my_feature") end, + visible = function(player, element) return feature:is_enabled() end, } --- Commands are denied and left out of help while disabled: -Commands.new("my_command", { "..." }):add_flags{ feature = "my_feature" } +Commands.new("my_command", { "..." }):add_flags{ feature = feature } + +--- Changes are raised per value, values derived from a list can use Feature.to_set: +[Feature.events.on_config_updated] = function(event) end, -- { feature_name, path, old_value, new_value } +if Feature.to_set(config.names)[name] then end ]] local Storage = require("modules/exp_util/storage") ---- @class ExpScenario.Features -local Features = { - events = { - --- Raised when the controller changes a feature, gui and commands are refreshed by control/features.lua - on_feature_changed = script.generate_event_name(), - }, -} - ---- @class ExpScenario.FeatureConfig ---- @field enabled boolean - ---- @alias ExpScenario.Feature string | ExpScenario.FeatureConfig - --- @class ExpScenario.FeatureField --- @field parent table The table holding the value within the config --- @field key string The key of the value within its parent --- @field default any --- @field type string "boolean", "number", "string", "list" of strings, or a type which can not be overridden ---- @class ExpScenario.Optional ---- @field __feature_optional string +--- @class ExpScenario.Feature +--- @field name string +--- @field config table Changed in place when the config is updated +--- @field fields table The values in the config by path +--- @field overrides table The overrides from the controller by path, restored from storage on load +local Feature = { + _registered = {}, --- @type table + events = { + --- Raised for each value which changes when the controller updates a feature + --- @type EventData.ExpScenario.on_config_updated + on_config_updated = script.generate_event_name(), + }, +} ---- The live config tables by feature name -local features = {} --- @type table +--- @class EventData.ExpScenario.on_config_updated : EventData +--- @field feature_name string +--- @field path string The path of the value within the config, "enabled" when the feature is enabled or disabled +--- @field old_value any +--- @field new_value any ---- The values of each feature by feature name then path -local fields = {} --- @type table> +local Feature_mt = { + __index = Feature, +} ---- Called after a feature is applied by feature name -local on_apply = {} --- @type table - ---- Overrides received from the controller by feature name then path -local overrides = {} --- @type table> - ---- Check if a table is a list, empty tables count as lists ---- @param tbl table ---- @return boolean -local function is_list(tbl) - return next(tbl) == nil or tbl[1] ~= nil -end +--- The overrides of each feature by name, only these are stored because the rest is rebuilt when the file loads +local stored_overrides = {} --- @type table> +Storage.register(stored_overrides, function(tbl) + stored_overrides = tbl + for name, feature in pairs(Feature._registered) do + feature.overrides = tbl[name] or {} + feature:_apply() + end +end) --- Check if a table is a list of strings, empty tables count as lists of strings --- @param tbl table --- @return boolean local function is_string_list(tbl) - if not is_list(tbl) then return false end + if next(tbl) ~= nil and tbl[1] == nil then return false end for _, item in pairs(tbl) do if type(item) ~= "string" then return false end end return true end ---- Check if an override can replace a field ---- @param field ExpScenario.FeatureField ---- @param value any +--- Check if two values are equal, lists are compared by their items +--- @param a any +--- @param b any --- @return boolean -local function is_valid(field, value) - if field.type == "list" then - return type(value) == "table" and is_string_list(value) +local function values_equal(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + if #a ~= #b then return false end + for index, item in ipairs(a) do + if b[index] ~= item then return false end end - return type(value) == field.type and (field.type == "boolean" or field.type == "number" or field.type == "string") -end - ---- Reset a feature to its defaults and apply its overrides ---- @param name string -local function apply(name) - local feature_overrides = overrides[name] or {} - for path, field in pairs(fields[name]) do - local value = feature_overrides[path] - if value == nil then - value = field.default - elseif not is_valid(field, value) then - log("[WARNING] Invalid override for " .. name .. "." .. path .. ", using the default") - value = field.default - end - field.parent[field.key] = value - end - - for _, callback in ipairs(on_apply[name]) do - callback(features[name]) - end -end - -Storage.register(overrides, function(tbl) - overrides = tbl - for name in pairs(features) do - apply(name) - end -end) - ---- Mark a value as having no default, the argument is the type it can be overridden with ---- @param value_type "boolean" | "number" | "string" | "list" ---- @return any -function Features.optional(value_type) - return { __feature_optional = value_type } + return true end --- Collect the values of a config table, recursing into nested tables which are not lists ---- @param feature_fields table +--- @param fields table --- @param tbl table --- @param prefix string -local function collect_fields(feature_fields, tbl, prefix) +local function collect_fields(fields, tbl, prefix) for key, value in pairs(tbl) do if type(key) == "string" then local field = { parent = tbl, key = key, default = value, type = type(value) } @@ -146,95 +116,97 @@ local function collect_fields(feature_fields, tbl, prefix) tbl[key] = nil elseif is_string_list(value) then field.type = "list" - elseif is_list(value) then + elseif value[1] ~= nil then field.type = "table" else - collect_fields(feature_fields, value, prefix .. key .. ".") + collect_fields(fields, value, prefix .. key .. ".") field = nil end end if field then - feature_fields[prefix .. key] = field + fields[prefix .. key] = field end end end end ---- Declare a feature and its config, features are enabled unless the config says otherwise ---- @generic T : table +--- Mark a value as having no default, the argument is the type it can be overridden with +--- @param value_type "boolean" | "number" | "string" | "list" +--- @return any +function Feature.optional(value_type) + return { __feature_optional = value_type } +end + +--- Register a feature and its config, features are enabled unless the config says otherwise --- @param name string ---- @param config T? ---- @return T -function Features.config(name, config) - assert(features[name] == nil, "Feature already declared: " .. name) - config = config or {} --[[@as T]] +--- @param config table? +--- @return ExpScenario.Feature +function Feature.register(name, config) + assert(Feature._registered[name] == nil, "Feature already registered: " .. name) + config = config or {} if config.enabled == nil then config.enabled = true end - local feature_fields = {} - collect_fields(feature_fields, config, "") + local fields = {} + collect_fields(fields, config, "") - features[name] = config - fields[name] = feature_fields - on_apply[name] = {} - return config + local feature = setmetatable({ + name = name, + config = config, + fields = fields, + overrides = {}, + }, Feature_mt) --[[@as ExpScenario.Feature]] + + Feature._registered[name] = feature + return feature end ---- Get the config of a feature from its name, or return the config given ---- @param feature ExpScenario.Feature ---- @return ExpScenario.FeatureConfig -local function resolve(feature) - if type(feature) == "table" then - return feature - end - return features[feature] or error("Unknown feature: " .. tostring(feature), 3) +--- Get a registered feature by name +--- @param name string +--- @return ExpScenario.Feature +function Feature.get(name) + return Feature._registered[name] or error("Unknown feature: " .. tostring(name), 2) end ---- Get the name of a feature from its config, or return the name given ---- @param feature ExpScenario.Feature ---- @return string -local function resolve_name(feature) - if type(feature) == "string" then - return feature - end - for name, config in pairs(features) do - if config == feature then - return name - end - end - error("Unknown feature config", 3) -end - ---- Check if a feature is enabled ---- @param feature ExpScenario.Feature +--- Check if a field can take a value +--- @param field ExpScenario.FeatureField +--- @param value any --- @return boolean -function Features.is_enabled(feature) - return resolve(feature).enabled +local function is_valid(field, value) + if field.type == "list" then + return type(value) == "table" and is_string_list(value) + end + return type(value) == field.type and (field.type == "boolean" or field.type == "number" or field.type == "string") end ---- Call a function now and whenever the config of a feature changes, used to rebuild values derived from it --- Also called during on_load, so it must only change locals and not the game state ---- @param feature ExpScenario.Feature ---- @param callback fun(config: any) -function Features.on_apply(feature, callback) - local name = resolve_name(feature) - table.insert(on_apply[name], callback) - callback(features[name]) +--- Set every value to its override, or its default when there is no valid override +--- @package +function Feature:_apply() + for path, field in pairs(self.fields) do + local value = self.overrides[path] + if value == nil then + value = field.default + elseif not is_valid(field, value) then + log("[WARNING] Invalid override for " .. self.name .. "." .. path .. ", using the default") + value = field.default + end + field.parent[field.key] = value + end +end + +--- Check if the feature is enabled +--- @return boolean +function Feature:is_enabled() + return self.config.enabled end --- Make the event and on_nth_tick handlers of an event handler lib do nothing while the feature is disabled --- A name which has not been declared is declared without config --- @generic T : table ---- @param feature ExpScenario.Feature --- @param lib T --- @return T -function Features.guard(feature, lib) - if type(feature) == "string" and features[feature] == nil then - Features.config(feature) - end - local config = resolve(feature) - +function Feature:guard(lib) + local config = self.config for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do for key, handler in pairs(handlers) do handlers[key] = function(event) @@ -248,6 +220,58 @@ function Features.guard(feature, lib) return lib end +--- Replace the overrides of the feature, then raise on_config_updated for each value which changed +--- @param enabled boolean +--- @param values table +function Feature:update_config(enabled, values) + local overrides = { enabled = enabled } + for path, value in pairs(values) do + if self.fields[path] == nil then + log("[WARNING] Unknown override for " .. self.name .. "." .. path) + end + overrides[path] = value + end + + local old_values = {} + for path, field in pairs(self.fields) do + old_values[path] = field.parent[field.key] + end + + self.overrides = overrides + stored_overrides[self.name] = overrides + self:_apply() + + for path, field in pairs(self.fields) do + local new_value = field.parent[field.key] + if not values_equal(old_values[path], new_value) then + script.raise_event(Feature.events.on_config_updated, { + feature_name = self.name, + path = path, + old_value = old_values[path], + new_value = new_value, + }) + end + end +end + +--- Sets made from lists in the config, keyed by the list so a replaced list gets a new set +local sets = setmetatable({}, { __mode = "k" }) --- @type table> + +--- Get a lookup set of the items in a list, cached until the list is replaced +--- @param list string[] +--- @return table +function Feature.to_set(list) + local set = sets[list] + if not set then + set = {} + for _, item in ipairs(list) do + set[item] = true + end + sets[list] = set + end + return set +end + --- @class ExpScenario.FeatureUpdate --- @field name string --- @field enabled boolean @@ -255,24 +279,13 @@ end --- Called over rcon by the instance plugin with the features which changed, unknown features are ignored --- @param updates ExpScenario.FeatureUpdate[] -function Features.receive_update(updates) +function Feature.receive_update(updates) for _, update in ipairs(updates) do - local name = update.name - local feature_fields = fields[name] - if feature_fields then - local override = { enabled = update.enabled } - for path, value in pairs(update.values) do - if feature_fields[path] == nil then - log("[WARNING] Unknown override for " .. name .. "." .. path) - end - override[path] = value - end - - overrides[name] = override - apply(name) - script.raise_event(Features.events.on_feature_changed, { feature_name = name }) + local feature = Feature._registered[update.name] + if feature then + feature:update_config(update.enabled, update.values) end end end -return Features +return Feature diff --git a/exp_scenario/module/gui/autofill.lua b/exp_scenario/module/gui/autofill.lua index f8b2a162..9a6b7093 100644 --- a/exp_scenario/module/gui/autofill.lua +++ b/exp_scenario/module/gui/autofill.lua @@ -2,10 +2,12 @@ Adds a config menu for setting autofill of placed entities ]] -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.gui.autofill") + +local feature = Feature.register("autofill") local FlyingText = require("modules/exp_util/flying_text") local min = math.min @@ -331,7 +333,7 @@ Gui.toolbar.create_button{ sprite = config.icon, tooltip = { "exp-gui_autofill.tooltip-main" }, visible = function(player, element) - return Features.is_enabled("autofill") and Roles.player_has_permission(player, "exp_scenario.gui.autofill") + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.autofill") end } @@ -397,9 +399,9 @@ end local e = defines.events -return Features.guard("autofill", { +return feature:guard{ elements = Elements, events = { [e.on_built_entity] = on_built_entity, }, -}) +} diff --git a/exp_scenario/module/gui/quick_actions.lua b/exp_scenario/module/gui/quick_actions.lua index 9a94bd9f..28c9ec37 100644 --- a/exp_scenario/module/gui/quick_actions.lua +++ b/exp_scenario/module/gui/quick_actions.lua @@ -4,7 +4,7 @@ Adds a few buttons for common actions local Gui = require("modules/exp_gui") local Commands = require("modules/exp_commands") -local Features = require("modules/exp_scenario/features") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local addon_artillery = require("modules/exp_scenario/commands/artillery") @@ -128,7 +128,9 @@ local function on_role_changed(event) end --- Actions for commands of a disabled feature are hidden -local function on_feature_changed() +--- @param event EventData.ExpScenario.on_config_updated +local function on_config_updated(event) + if event.path ~= "enabled" then return end for _, player in pairs(game.connected_players) do Elements.container.refresh_player(player) end @@ -138,6 +140,6 @@ return { elements = Elements, events = { [Roles.events.on_player_roles_changed] = on_role_changed, - [Features.events.on_feature_changed] = on_feature_changed, + [Feature.events.on_config_updated] = on_config_updated, } } diff --git a/exp_scenario/test/features.test.js b/exp_scenario/test/features.test.js index 2e3bf640..c5974ca3 100644 --- a/exp_scenario/test/features.test.js +++ b/exp_scenario/test/features.test.js @@ -17,8 +17,8 @@ function luaFiles(dir, prefix = "") { const moduleFiles = luaFiles(path.join(import.meta.dirname, "..", "module")); /** - * Read a default from a Features.config call, a path of "a.b" looks for b after the table a starts. - * Only covers booleans, numbers, strings, lists of strings, and Features.optional. + * Read a default from a Feature.register call, a path of "a.b" looks for b after the table a starts. + * Only covers booleans, numbers, strings, lists of strings, and Feature.optional. */ function luaDefault(source, path) { const keys = path.split("."); @@ -32,13 +32,13 @@ function luaDefault(source, path) { } const key = keys.at(-1); - const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Features\\.optional\\("\\w+"\\)|\\{[^{}]*\\})`, "m") + const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Feature\\.optional\\("\\w+"\\)|\\{[^{}]*\\})`, "m") .exec(source.slice(start)); if (!match) { return undefined; } const literal = match[1]; - if (literal.startsWith("Features.optional")) { + if (literal.startsWith("Feature.optional")) { return null; } if (literal.startsWith("{")) { @@ -51,7 +51,7 @@ t.test("features", t2 => { for (const feature of features) { t2.test(feature.name, t3 => { const declarations = moduleFiles.filter(([, source]) => ( - source.includes(`Features.config("${feature.name}"`) || source.includes(`Features.guard("${feature.name}"`) + source.includes(`Feature.register("${feature.name}"`) )); t3.equal(declarations.length, 1, "one lua file declares the feature"); const source = declarations[0]?.[1] ?? ""; @@ -92,8 +92,8 @@ t.test("validateFeatureValues()", t2 => { { message: "Setting always_protected_types of protection must be a list of strings" }, ); t2.throws( - () => validateFeatureValues("afk_kick", { active_role_id: 1.5 }), - { message: "Setting active_role_id of afk_kick must be a role id or null" }, + () => validateFeatureValues("afk_kick", { afk_minutes: null }), + { message: "Setting afk_minutes of afk_kick can not be null" }, ); t2.same( validateFeatureValues("protection", { always_protected_types: ["boiler"], always_trigger_repeat_types: ["reactor", "fusion-reactor", "rocket-silo"] }), diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua index 75d17960..0121e16f 100644 --- a/exp_scenario/test/module/env.lua +++ b/exp_scenario/test/module/env.lua @@ -13,10 +13,10 @@ local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$")) local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework --- @class ExpScenario.TestEnv : Stubs ---- @field Features ExpScenario.Features A fresh copy of the features module +--- @field Feature ExpScenario.Feature A fresh copy of the features module --- @field storage table The table registered with storage, as on_init would store it --- @field on_load fun(tbl: table) Act out on_load with the given storage table ---- @field load_config fun(name: string): table Load module/config/.lua +--- @field load_config fun(name: string): ExpScenario.Feature Load module/config/.lua --- @field logged string[] Messages passed to log return Framework.suite(function(env) @@ -33,8 +33,8 @@ return Framework.suite(function(env) }, } - env.Features = assert(loadfile(plugin_root .. "/module/features.lua"))() - env.extend_requires{ ["modules/exp_scenario/features"] = env.Features } + env.Feature = assert(loadfile(plugin_root .. "/module/features.lua"))() + env.extend_requires{ ["modules/exp_scenario/features"] = env.Feature } function env.on_load(tbl) env.storage = tbl diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua index 2c195d6d..afb806b2 100644 --- a/exp_scenario/test/module/features.lua +++ b/exp_scenario/test/module/features.lua @@ -1,137 +1,140 @@ local Suite = ... --- @type Suite -Suite.test("config() enables features unless the defaults say otherwise", function(env) - Suite.eq(env.Features.config("on", { value = 1 }).enabled, true, "enabled by default") - Suite.eq(env.Features.config("off", { enabled = false }).enabled, false, "the defaults can disable it") - Suite.eq(env.Features.config("empty"), { enabled = true }, "config is optional") - Suite.throws(function() env.Features.config("on", {}) end, "Feature already declared: on", "names are unique") +--- The values of on_config_updated events, ignoring the event name and tick +local function updates(env) + local rtn = {} + for _, event in ipairs(env.events) do + rtn[#rtn + 1] = { event.feature_name, event.path, event.old_value, event.new_value } + end + return rtn +end + +Suite.test("register() enables features unless the config says otherwise", function(env) + Suite.eq(env.Feature.register("on", { value = 1 }).config.enabled, true, "enabled by default") + Suite.eq(env.Feature.register("off", { enabled = false }):is_enabled(), false, "the config can disable it") + Suite.eq(env.Feature.register("empty").config, { enabled = true }, "config is optional") + Suite.throws(function() env.Feature.register("on", {}) end, "Feature already registered: on", "names are unique") end) -Suite.test("is_enabled() takes a name or a config", function(env) - local config = env.Features.config("test", { enabled = false }) - Suite.eq(env.Features.is_enabled("test"), false, "by name") - Suite.eq(env.Features.is_enabled(config), false, "by config") - Suite.throws(function() env.Features.is_enabled("unknown") end, "Unknown feature: unknown", "unknown names error") +Suite.test("get() finds registered features", function(env) + local feature = env.Feature.register("test") + Suite.eq(env.Feature.get("test") == feature, true, "by name") + Suite.throws(function() env.Feature.get("unknown") end, "Unknown feature: unknown", "unknown names error") end) -Suite.test("receive_update() changes the config in place", function(env) - local config = env.Features.config("test", { flag = true, count = 5, text = "a" }) - env.Features.receive_update{ { name = "test", enabled = false, values = { flag = false, count = 7 } } } +Suite.test("update_config() changes the config in place", function(env) + local feature = env.Feature.register("test", { flag = true, count = 5, text = "a" }) + local config = feature.config + feature:update_config(false, { flag = false, count = 7 }) Suite.eq(config, { enabled = false, flag = false, count = 7, text = "a" }, "given values replace the defaults") - env.Features.receive_update{ { name = "test", enabled = true, values = { text = "b" } } } + feature:update_config(true, { text = "b" }) Suite.eq(config, { enabled = true, flag = true, count = 5, text = "b" }, "values left out go back to their default") Suite.eq(env.storage, { test = { enabled = true, text = "b" } }, "the overrides are kept in storage") end) -Suite.test("receive_update() raises on_feature_changed for each known feature", function(env) - env.Features.config("test") - env.Features.receive_update{ - { name = "test", enabled = false, values = {} }, - { name = "unknown", enabled = false, values = {} }, - } - Suite.eq(env.events, { - { name = env.Features.events.on_feature_changed, tick = game.tick, feature_name = "test" }, - }, "raised once") +Suite.test("update_config() raises on_config_updated for each changed value", function(env) + local feature = env.Feature.register("test", { flag = true, names = { "a" }, section = { count = 1 } }) + feature:update_config(false, { names = { "a" }, ["section.count"] = 2 }) + Suite.eq(#env.events, 2, "unchanged values raise nothing") + Suite.eq(env.Feature.events.on_config_updated, assert(env.events[1]).name, "the event is on_config_updated") + + local seen = updates(env) + table.sort(seen, function(a, b) return a[2] < b[2] end) + Suite.eq(seen, { + { "test", "enabled", true, false }, + { "test", "section.count", 1, 2 }, + }, "with the path, old value, and new value") end) -Suite.test("receive_update() ignores values which can not be overridden", function(env) +Suite.test("update_config() ignores and logs values which can not be overridden", function(env) local function check() return true end - local config = env.Features.config("test", { flag = true, check = check, icon = nil }) - env.Features.receive_update{ + local feature = env.Feature.register("test", { flag = true, check = check, icon = nil }) + feature:update_config(true, { flag = "yes", check = false, icon = "x" }) + Suite.eq(feature.config, { enabled = true, flag = true, check = check }, "wrong types, functions, and unknown keys are skipped") + Suite.eq(#env.logged, 3, "each one is logged") +end) + +Suite.test("receive_update() updates known features", function(env) + local feature = env.Feature.register("test", { flag = true }) + env.Feature.receive_update{ + { name = "test", enabled = true, values = { flag = false } }, { name = "unknown", enabled = false, values = {} }, - { name = "test", enabled = true, values = { flag = "yes", check = false, icon = "x", extra = 1 } }, } - Suite.eq(config, { enabled = true, flag = true, check = check }, "wrong types, functions, and unknown keys are skipped") + Suite.eq(feature.config.flag, false, "the known feature changed") Suite.eq(env.storage.unknown, nil, "unknown features are not stored") end) -Suite.test("on_load applies the overrides from storage", function(env) - local config = env.Features.config("test", { flag = true }) +Suite.test("on_load applies the overrides from storage without raising events", function(env) + local feature = env.Feature.register("test", { flag = true }) env.on_load{ test = { enabled = false, flag = false } } - Suite.eq(config, { enabled = false, flag = false }, "the saved overrides apply") + Suite.eq(feature.config, { enabled = false, flag = false }, "the saved overrides apply") + Suite.empty(env.events, "nothing is raised") - env.Features.receive_update{ { name = "test", enabled = true, values = {} } } + feature:update_config(true, {}) Suite.eq(env.storage, { test = { enabled = true } }, "later updates go to the loaded storage table") end) Suite.test("guard() skips handlers while the feature is disabled", function(env) - local config = env.Features.config("test") + local feature = env.Feature.register("test") local calls = {} - local lib = env.Features.guard(config, { + local lib = feature:guard{ events = { on_event = function(event) calls[#calls + 1] = event end }, on_nth_tick = { [60] = function(event) calls[#calls + 1] = event end }, on_init = function() end, - }) + } lib.events.on_event("a") lib.on_nth_tick[60]("b") - env.Features.receive_update{ { name = "test", enabled = false, values = {} } } + feature:update_config(false, {}) lib.events.on_event("c") lib.on_nth_tick[60]("d") Suite.eq(calls, { "a", "b" }, "only called while enabled") end) -Suite.test("guard() declares features by name", function(env) - local calls = 0 - local lib = env.Features.guard("test", { events = { on_event = function() calls = calls + 1 end } }) - env.Features.receive_update{ { name = "test", enabled = false, values = {} } } - lib.events.on_event() - Suite.eq(calls, 0, "the name is declared without config") - Suite.eq(env.Features.is_enabled("test"), false, "and can be looked up") -end) - -Suite.test("receive_update() logs invalid and unknown overrides", function(env) - local config = env.Features.config("test", { flag = true }) - env.Features.receive_update{ { name = "test", enabled = true, values = { flag = 1, extra = 2 } } } - Suite.eq(config.flag, true, "the default is used") - Suite.eq(#env.logged, 2, "both are logged") -end) - -Suite.test("config() addresses nested values by path", function(env) +Suite.test("register() addresses nested values by path", function(env) local offset = { 1, 2 } - local config = env.Features.config("test", { section = { flag = true, count = 1 }, offset = offset }) - env.Features.receive_update{ { name = "test", enabled = true, values = { ["section.flag"] = false, offset = { "x" } } } } - Suite.eq(config.section, { flag = false, count = 1 }, "the nested value changes in place") - Suite.eq(config.offset, offset, "lists of other types can not be overridden") + local feature = env.Feature.register("test", { section = { flag = true, count = 1 }, offset = offset }) + feature:update_config(true, { ["section.flag"] = false, offset = { "x" } }) + Suite.eq(feature.config.section, { flag = false, count = 1 }, "the nested value changes in place") + Suite.eq(feature.config.offset, offset, "lists of other types can not be overridden") - env.Features.receive_update{ { name = "test", enabled = true, values = {} } } - Suite.eq(config.section.flag, true, "and goes back to its default") + feature:update_config(true, {}) + Suite.eq(feature.config.section.flag, true, "and goes back to its default") end) -Suite.test("config() allows lists of strings to be overridden", function(env) - local config = env.Features.config("test", { names = { "a", "b" }, empty = {} }) - env.Features.receive_update{ { name = "test", enabled = true, values = { names = { "c" }, empty = { "d" } } } } - Suite.eq(config.names, { "c" }, "a list is replaced") - Suite.eq(config.empty, { "d" }, "an empty default is a list") - env.Features.receive_update{ { name = "test", enabled = true, values = { names = { 1 } } } } - Suite.eq(config.names, { "a", "b" }, "a list with other types is invalid") +Suite.test("register() allows lists of strings to be overridden", function(env) + local feature = env.Feature.register("test", { names = { "a", "b" }, empty = {} }) + feature:update_config(true, { names = { "c" }, empty = { "d" } }) + Suite.eq(feature.config.names, { "c" }, "a list is replaced") + Suite.eq(feature.config.empty, { "d" }, "an empty default is a list") + feature:update_config(true, { names = { 1 } }) + Suite.eq(feature.config.names, { "a", "b" }, "a list with other types is invalid") end) Suite.test("optional() declares a value without a default", function(env) - local config = env.Features.config("test", { role_id = env.Features.optional("number") }) - Suite.eq(config.role_id, nil, "nil until overridden") - env.Features.receive_update{ { name = "test", enabled = true, values = { role_id = 3 } } } - Suite.eq(config.role_id, 3, "overridden with the given type") - env.Features.receive_update{ { name = "test", enabled = true, values = {} } } - Suite.eq(config.role_id, nil, "and back to nil") + local feature = env.Feature.register("test", { role_id = env.Feature.optional("number") }) + Suite.eq(feature.config.role_id, nil, "nil until overridden") + feature:update_config(true, { role_id = 3 }) + Suite.eq(feature.config.role_id, 3, "overridden with the given type") + feature:update_config(true, {}) + Suite.eq(feature.config.role_id, nil, "and back to nil") end) -Suite.test("on_apply() is called now and after every change", function(env) - local config = env.Features.config("test", { names = { "a" } }) - local seen = {} - env.Features.on_apply("test", function(applied) seen[#seen + 1] = applied.names[1] end) - env.Features.receive_update{ { name = "test", enabled = true, values = { names = { "b" } } } } - env.on_load{ test = { enabled = true, names = { "c" } } } - Suite.eq(seen, { "a", "b", "c" }, "on register, update, and load") - Suite.eq(config.names, { "c" }, "after the config is applied") +Suite.test("to_set() is cached until the list is replaced", function(env) + local feature = env.Feature.register("test", { names = { "a" } }) + local set = env.Feature.to_set(feature.config.names) + Suite.eq(set, { a = true }, "a set of the items") + Suite.eq(env.Feature.to_set(feature.config.names) == set, true, "the same set while the list is unchanged") + env.on_load{ test = { enabled = true, names = { "b" } } } + Suite.eq(env.Feature.to_set(feature.config.names), { b = true }, "a new set after load replaces the list") end) -Suite.test("config files declare their feature", function(env) - local config = env.load_config("popups") - env.Features.receive_update{ { name = "popups", enabled = true, values = { show_player_damage = false } } } - Suite.eq(config.show_player_damage, false, "popups can be changed") - Suite.eq(config.show_player_health, true, "other values keep their default") +Suite.test("config files register their feature", function(env) + local feature = env.load_config("popups") + feature:update_config(true, { show_player_damage = false }) + Suite.eq(feature.config.show_player_damage, false, "popups can be changed") + Suite.eq(feature.config.show_player_health, true, "other values keep their default") end) return Suite.run() diff --git a/exp_scenario/web/components/FeaturesPage.tsx b/exp_scenario/web/components/FeaturesPage.tsx index 59f961a5..118384df 100644 --- a/exp_scenario/web/components/FeaturesPage.tsx +++ b/exp_scenario/web/components/FeaturesPage.tsx @@ -1,7 +1,8 @@ import React, { useContext, useState } from "react"; -import { Button, Checkbox, Col, Divider, Input, InputNumber, Row, Select, Space, Switch } from "antd"; +import { Button, Checkbox, Col, ConfigProvider, Divider, Input, InputNumber, Row, Select, Space, Switch } from "antd"; -import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount, useRoles } from "@clusterio/web_ui"; +import * as lib from "@clusterio/lib"; +import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount } from "@clusterio/web_ui"; import { Feature, FeatureField, FeatureValue, features, isDefaultValue } from "../../features.js"; import { FeatureRecord, FeatureUpdateRequest } from "../../messages.js"; @@ -70,24 +71,30 @@ function searchFields(feature: Feature, query: string) { )); } -function RoleSelect(props: { value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) { - const [roles, synced] = useRoles(); - return onChange(e.target.value)} style={{ maxWidth: 400 }} />; + if (field.enum) { + return onChange(e.target.value)} style={{ width: 300 }} />; case "string_list": return