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] 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); }