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