From 4bc1773c8290d435fffd80007b4013306e1ee2a0 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:28:23 +0000 Subject: [PATCH] Address review: page layout, five more features, framework changes Framework: - Intervals are gone. Update periods are hardcoded on_nth_tick values again, since nobody has needed to change them. - apply is a single loop over the declared fields and logs a warning when an override is invalid. receive_update also warns about unknown keys. - Nested tables are addressed as "section.key". Lists of strings can be overridden. Features.optional declares a value with no default. - Features.on_apply rebuilds values derived from the config, such as lookup sets. It also runs during on_load. exp_roles is assumed to be present: - AFK Kick picks a role (active_role_id) instead of a custom check function. - Chat Auto Reply, Protection, and Nuke Protection no longer take a permission as a setting. New features, each chosen for an edge case: - Discord Alerts: conditional handlers, and a command map that becomes a list. - Entity Protection: lists turned into sets at load, and bookkeeping that must keep running while disabled. - Nuke Protection: config keyed by defines, flattened to a banned items list. - Popups: one config shared by two control files. - Spawn Area: nested sections, and settings that only apply when the map is created. Page: now laid out like the role page. It has a search across every setting, one long list with a sticky sidebar to jump between sections, and the enabled switch separated at the front of each header with Reset to defaults on the right. Changed rows are highlighted, and a bottom bar offers Revert and Apply. Role settings use a role picker and lists use a tag select. Co-Authored-By: Claude Opus 5.5 --- exp_legacy/module/config/discord_alerts.lua | 23 - exp_legacy/module/config/nukeprotect.lua | 30 -- exp_legacy/module/config/protection.lua | 17 - exp_scenario/features.ts | 327 +++++++------- exp_scenario/messages.ts | 7 +- exp_scenario/module/config/afk_kick.lua | 18 - .../module/config/chat_auto_reply.lua | 1 - .../module/config/popups.lua | 10 +- .../module/config/spawn_area.lua | 8 +- exp_scenario/module/control/afk_kick.lua | 24 +- .../module/control/chat_auto_reply.lua | 2 +- exp_scenario/module/control/chat_popup.lua | 9 +- exp_scenario/module/control/damage_popups.lua | 7 +- exp_scenario/module/control/death_markers.lua | 5 +- .../module/control/discord_alerts.lua | 306 +++++++------ .../module/control/nuke_protection.lua | 49 ++- exp_scenario/module/control/protection.lua | 46 +- exp_scenario/module/control/spawn_area.lua | 45 +- exp_scenario/module/features.lua | 194 ++++++--- exp_scenario/test/features.test.js | 51 ++- exp_scenario/test/module/env.lua | 3 + exp_scenario/test/module/features.lua | 68 ++- exp_scenario/web/components/FeaturesPage.tsx | 409 +++++++++++++----- 23 files changed, 989 insertions(+), 670 deletions(-) delete mode 100644 exp_legacy/module/config/discord_alerts.lua delete mode 100644 exp_legacy/module/config/nukeprotect.lua delete mode 100644 exp_legacy/module/config/protection.lua delete mode 100644 exp_scenario/module/config/afk_kick.lua rename exp_legacy/module/config/popup_messages.lua => exp_scenario/module/config/popups.lua (74%) rename {exp_legacy => exp_scenario}/module/config/spawn_area.lua (99%) diff --git a/exp_legacy/module/config/discord_alerts.lua b/exp_legacy/module/config/discord_alerts.lua deleted file mode 100644 index bc51d78c..00000000 --- a/exp_legacy/module/config/discord_alerts.lua +++ /dev/null @@ -1,23 +0,0 @@ ---- Config file used to enable and disable different push messages for discord --- @config Discord-Alerts - -return { - show_playtime = true, - entity_protection = true, - player_bans = true, - player_mutes = true, - player_kicks = true, - player_promotes = false, - player_jail = true, - ["config"] = true, - ["purge"] = true, - ["c"] = true, - ["command"] = true, - ["silent-command"] = true, - ["measured-command"] = true, - ["banlist"] = true, - ["permissions"] = true, - ["editor"] = true, - ["cheat"] = true, - ["open"] = false, -} diff --git a/exp_legacy/module/config/nukeprotect.lua b/exp_legacy/module/config/nukeprotect.lua deleted file mode 100644 index 197ddb00..00000000 --- a/exp_legacy/module/config/nukeprotect.lua +++ /dev/null @@ -1,30 +0,0 @@ -return { - inventories = { - { - inventory = defines.inventory.character_ammo, - event = defines.events.on_player_ammo_inventory_changed, - items = { - ["atomic-bomb"] = true, - }, - }, - { - inventory = defines.inventory.character_armor, - event = defines.events.on_player_armor_inventory_changed, - items = {}, - }, - { - inventory = defines.inventory.character_guns, - event = defines.events.on_player_gun_inventory_changed, - items = {}, - }, - { - inventory = defines.inventory.character_main, - event = defines.events.on_player_main_inventory_changed, - items = { - ["atomic-bomb"] = true, - }, - }, - }, - ignore_permission = "exp_scenario.bypass.nuke_protection", -- @setting ignore_permission The permission that nukeprotect will ignore - ignore_admins = true, -- @setting ignore_admins Ignore admins, true by default. Allows usage outside of the roles module -} diff --git a/exp_legacy/module/config/protection.lua b/exp_legacy/module/config/protection.lua deleted file mode 100644 index d645d04b..00000000 --- a/exp_legacy/module/config/protection.lua +++ /dev/null @@ -1,17 +0,0 @@ -return { - ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter - ignore_permission = "exp_scenario.bypass.entity_protection", --- @setting ignore_permission Players with this permission will be ignored by the protection filter, leave nil to protect against every player - repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_lifetime in order to trigger repeated removal protection - repeat_lifetime = 3600 * 20, --- @setting repeat_lifetime The length of time, in ticks, that protected removals will be remembered for - refresh_rate = 3600 * 5, --- @setting refresh_rate How often the age of protected removals are checked against repeat_lifetime - always_protected_names = { --- @setting always_protected_names Names of entities which are always protected - }, - always_protected_types = { --- @setting always_protected_types Types of entities which are always protected - "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", - }, - always_trigger_repeat_names = { --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection - }, - always_trigger_repeat_types = { --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection - "reactor", "fusion-reactor", "rocket-silo", - }, -} diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts index c20b6459..fa97660b 100644 --- a/exp_scenario/features.ts +++ b/exp_scenario/features.ts @@ -3,10 +3,10 @@ * * Defaults must match the Features.config call in the lua module, * test/features.test.js checks this. Only list settings which the lua side - * reads at runtime. + * reads at runtime. Settings in nested tables are named "section.key". */ -export type FeatureValue = boolean | number | string; +export type FeatureValue = boolean | number | string | string[] | null; export type FeatureField = { name: string, @@ -16,6 +16,9 @@ export type FeatureField = { | { type: "boolean", default: boolean } | { type: "number", default: number, min?: number, unit?: string } | { type: "string", default: string } + | { type: "string_list", default: string[] } + /** Id of a clusterio role, null when no role is picked */ + | { type: "role", default: null } ); export type Feature = { @@ -25,62 +28,38 @@ export type Feature = { fields: FeatureField[], }; +function bool(name: string, title: string, description: string, value: boolean): FeatureField { + return { name, title, description, type: "boolean", default: value }; +} + +function num(name: string, title: string, description: string, value: number, min?: number, unit?: string): FeatureField { + return { name, title, description, type: "number", default: value, min, unit }; +} + +function str(name: string, title: string, description: string, value: string): FeatureField { + return { name, title, description, type: "string", default: value }; +} + +function list(name: string, title: string, description: string, value: string[]): FeatureField { + return { name, title, description, type: "string_list", default: value }; +} + +function role(name: string, title: string, description: string): FeatureField { + return { name, title, description, type: "role", default: null }; +} + export const features: Feature[] = [ { name: "afk_kick", title: "AFK Kick", description: "Kick everyone when nobody online is active", fields: [ - { - name: "admin_as_active", - title: "Admins count as active", - description: "Admins are active regardless of how long they have been afk", - type: "boolean", - default: true, - }, - { - name: "trust_as_active", - title: "Trusted players count as active", - description: "Players with enough playtime are active regardless of how long they have been afk", - type: "boolean", - default: true, - }, - { - name: "afk_minutes", - title: "AFK after", - description: "Time without input before a player counts as afk", - type: "number", - default: 10, - min: 1, - unit: "minutes", - }, - { - name: "kick_minutes", - title: "Kick after", - description: "Time without any active player before everyone is kicked", - type: "number", - default: 30, - min: 1, - unit: "minutes", - }, - { - name: "trust_minutes", - title: "Trusted after", - description: "Playtime needed before a player counts as trusted", - type: "number", - default: 600, - min: 0, - unit: "minutes", - }, - { - name: "update_seconds", - title: "Check every", - description: "How often to check for active players", - type: "number", - default: 1800, - min: 1, - unit: "seconds", - }, + bool("admin_as_active", "Admins count as active", "Admins are active regardless of how long they have been afk", true), + bool("trust_as_active", "Trusted players count as active", "Players with enough playtime are active regardless of how long they have been afk", true), + role("active_role_id", "Role counts as active", "Players with this role or higher are active regardless of how long they have been afk"), + num("afk_minutes", "AFK after", "Time without input before a player counts as afk", 10, 1, "minutes"), + num("kick_minutes", "Kick after", "Time without any active player before everyone is kicked", 30, 1, "minutes"), + num("trust_minutes", "Trusted after", "Playtime needed before a player counts as trusted", 600, 0, "minutes"), ], }, { @@ -94,20 +73,8 @@ export const features: Feature[] = [ title: "Chat Auto Reply", description: "Reply to key words in chat and run chat commands", fields: [ - { - name: "command_prefix", - title: "Command prefix", - description: "Prefix for chat commands, and for key words to reply to everyone", - type: "string", - default: "!", - }, - { - name: "command_admin_only", - title: "Commands for admins only", - description: "Only admins can use chat commands", - type: "boolean", - default: false, - }, + str("command_prefix", "Command prefix", "Prefix for chat commands, and for key words to reply to everyone", "!"), + bool("command_admin_only", "Commands for admins only", "Only admins can use chat commands", false), ], }, { @@ -115,57 +82,29 @@ export const features: Feature[] = [ title: "Death Markers", description: "What happens when players die", fields: [ - { - name: "collect_corpses", - title: "Collect corpses", - description: "Put the items of expired corpses in chests", - type: "boolean", - default: true, - }, - { - name: "show_map_markers", - title: "Show map markers", - description: "Add a map marker where a player died", - type: "boolean", - default: true, - }, - { - name: "clean_map_markers", - title: "Remove map markers", - description: "Remove the map marker once the corpse is gone", - type: "boolean", - default: false, - }, - { - name: "include_time_of_death", - title: "Include time of death", - description: "Add the time of death to the map marker", - type: "boolean", - default: true, - }, - { - name: "show_light_at_corpse", - title: "Light at corpse", - description: "Draw a light in the player's colour at their corpse", - type: "boolean", - default: true, - }, - { - name: "show_line_to_corpse", - title: "Line to corpse", - description: "Draw a line from a respawned player to their corpse", - type: "boolean", - default: true, - }, - { - name: "check_map_tags_seconds", - title: "Check map markers every", - description: "How often to replace map markers which were removed", - type: "number", - default: 300, - min: 1, - unit: "seconds", - }, + bool("collect_corpses", "Collect corpses", "Put the items of expired corpses in chests", true), + bool("show_map_markers", "Show map markers", "Add a map marker where a player died", true), + bool("clean_map_markers", "Remove map markers", "Remove the map marker once the corpse is gone", false), + bool("include_time_of_death", "Include time of death", "Add the time of death to the map marker", true), + bool("show_light_at_corpse", "Light at corpse", "Draw a light in the player's colour at their corpse", true), + bool("show_line_to_corpse", "Line to corpse", "Draw a line from a respawned player to their corpse", true), + ], + }, + { + name: "discord_alerts", + title: "Discord Alerts", + description: "Write alerts for the discord bot when moderation events happen", + fields: [ + bool("show_playtime", "Show playtime", "Add the playtime of players to alerts", true), + bool("entity_protection", "Entity protection", "Alert when a player repeatedly removes protected entities", true), + bool("player_bans", "Bans", "Alert when a player is banned or unbanned", true), + bool("player_mutes", "Mutes", "Alert when a player is muted or unmuted", true), + bool("player_kicks", "Kicks", "Alert when a player is kicked", true), + bool("player_promotes", "Promotions", "Alert when a player is promoted or demoted", false), + bool("player_jail", "Jail", "Alert when a player is jailed or unjailed", true), + list("logged_commands", "Logged commands", "Alert when a player uses one of these commands", [ + "config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat", + ]), ], }, { @@ -173,13 +112,7 @@ export const features: Feature[] = [ title: "Lawnmower", description: "The /lawnmower command which clears corpses and nuclear ground", fields: [ - { - name: "destroy_decoratives", - title: "Clear decoratives when building", - description: "Remove decoratives under entities when they are built", - type: "boolean", - default: false, - }, + bool("destroy_decoratives", "Clear decoratives when building", "Remove decoratives under entities when they are built", false), ], }, { @@ -187,27 +120,76 @@ export const features: Feature[] = [ title: "Mine Depletion", description: "Deconstruct mining drills when their resources run out", fields: [ - { - name: "chest", - title: "Remove output chests", - description: "Also deconstruct the chest a drill outputs into", - type: "boolean", - default: true, - }, - { - name: "beacon", - title: "Remove beacons", - description: "Also deconstruct beacons which no longer affect anything", - type: "boolean", - default: true, - }, - { - name: "fluid", - title: "Replace with pipes", - description: "Place pipe ghosts where a drill carried fluid", - type: "boolean", - default: true, - }, + bool("chest", "Remove output chests", "Also deconstruct the chest a drill outputs into", true), + bool("beacon", "Remove beacons", "Also deconstruct beacons which no longer affect anything", true), + bool("fluid", "Replace with pipes", "Place pipe ghosts where a drill carried fluid", true), + ], + }, + { + name: "nuke_protection", + title: "Nuke Protection", + description: "Remove banned items from players without the nuke protection bypass permission", + fields: [ + bool("ignore_admins", "Ignore admins", "Admins can hold banned items", true), + list("banned_items", "Banned items", "Items which are moved to a chest at spawn", ["atomic-bomb"]), + ], + }, + { + name: "popups", + title: "Popups", + description: "Flying text for chat messages and damage", + fields: [ + bool("show_player_messages", "Chat messages", "Show chat messages above the player who sent them", true), + bool("show_player_mentions", "Mentions", "Show a popup above players mentioned in chat", true), + bool("show_player_damage", "Damage dealt", "Show the damage players deal to entities", true), + bool("show_player_health", "Player health", "Show the health of players when they are attacked", true), + num("damage_location_variance", "Damage spread", "How far from the centre of an entity damage popups appear, as a fraction of its size", 0.8, 0), + ], + }, + { + name: "protection", + title: "Entity Protection", + description: "Stop players removing protected entities placed by others, players with the bypass permission are ignored", + fields: [ + bool("ignore_admins", "Ignore admins", "Admins can remove protected entities", true), + num("repeat_count", "Repeat count", "Protected entities removed within the repeat time which count as a repeat violation", 5, 1), + num("repeat_minutes", "Repeat time", "How long removed protected entities are remembered", 20, 1, "minutes"), + list("always_protected_names", "Protected entity names", "Entities which are always protected", []), + list("always_protected_types", "Protected entity types", "Entity types which are always protected", [ + "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", + ]), + list("always_trigger_repeat_names", "Repeat entity names", "Entities which count as a repeat violation straight away", []), + list("always_trigger_repeat_types", "Repeat entity types", "Entity types which count as a repeat violation straight away", [ + "reactor", "fusion-reactor", "rocket-silo", + ]), + ], + }, + { + name: "spawn_area", + title: "Spawn Area", + description: "Build the spawn area when the first player joins, only the turret and resource refills apply to an existing map", + fields: [ + num("spawn_area.deconstruction_radius", "Clear radius", "Entities within this radius are removed", 20, 0, "tiles"), + num("spawn_area.tile_radius", "Tile radius", "Tiles within this radius are replaced", 20, 0, "tiles"), + str("spawn_area.deconstruction_tile", "Tile", "Tile placed within the tile radius", "concrete"), + num("spawn_area.landfill_radius", "Landfill radius", "Water within this radius is filled", 50, 0, "tiles"), + bool("turrets.enabled", "Turrets", "Place turrets at spawn and keep them refilled", true), + str("turrets.ammo_type", "Turret ammo", "Ammo used to refill the turrets", "uranium-rounds-magazine"), + bool("afk_belts.enabled", "AFK belts", "Place belt loops at spawn", true), + str("afk_belts.belt_type", "Belt type", "Belt used for the loops", "transport-belt"), + bool("afk_belts.protected", "Protect belts", "Players can not remove the belts", true), + bool("water.enabled", "Water", "Place water tiles at spawn", true), + str("water.water_tile", "Water tile", "Tile used for the water", "water-mud"), + bool("entities.enabled", "Entities", "Place walls, chests, lamps, and poles at spawn", true), + bool("entities.protected", "Protect entities", "Players can not remove the entities", true), + bool("entities.operable", "Operable entities", "Players can open the entities, needed for the chests", true), + bool("pattern.enabled", "Pattern", "Place a tile pattern at spawn", true), + str("pattern.pattern_tile", "Pattern tile", "Tile used for the pattern", "stone-path"), + bool("resource_refill_nearby.enabled", "Refill resources", "Add to resources near spawn every 10 minutes", false), + num("resource_refill_nearby.range", "Refill range", "Distance from spawn which resources are refilled", 128, 0, "tiles"), + list("resource_refill_nearby.resources_name", "Refilled resources", "Resources which are refilled", [ + "iron-ore", "copper-ore", "stone", "coal", "uranium-ore", + ]), ], }, { @@ -215,13 +197,7 @@ export const features: Feature[] = [ title: "Station Auto Name", description: "Name train stops when they are built", fields: [ - { - name: "station_name", - title: "Station name", - description: "Placeholders: __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__", - type: "string", - default: "[L] __icon__", - }, + str("station_name", "Station name", "Placeholders: __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__", "[L] __icon__"), ], }, ]; @@ -244,18 +220,45 @@ export function validateFeatureValues(name: string, values: Record typeof item !== "string")) { + throw new Error(`${label} must be a list of strings`); + } + return; + case "role": + if (value !== null && !Number.isInteger(value)) { + throw new Error(`${label} must be a role id or null`); + } + return; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number`); + } + if (field.min !== undefined && value < field.min) { + throw new Error(`${label} must be at least ${field.min}`); + } + return; + default: + if (typeof value !== field.type) { + throw new Error(`${label} must be a ${field.type}`); + } + } +} + +/** Check if a value equals the default of its field, lists are compared by content. */ +export function isDefaultValue(field: FeatureField, value: FeatureValue) { + if (Array.isArray(value) && Array.isArray(field.default)) { + return value.length === field.default.length && value.every((item, index) => item === (field.default as string[])[index]); + } + return value === field.default; +} diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index 17fdfc62..52feae15 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -1,5 +1,6 @@ import * as lib from "@clusterio/lib"; import { Type, Static } from "@sinclair/typebox"; +import type { FeatureValue } from "./features.js"; /** Create the roles and permission groups the scenario shipped with, see seed.ts. */ export class SeedRequest { @@ -13,7 +14,7 @@ export class SeedRequest { constructor() {} } -const FeatureValueSchema = Type.Union([Type.Boolean(), Type.Number(), Type.String()]); +const FeatureValueSchema = Type.Union([Type.Boolean(), Type.Number(), Type.String(), Type.Array(Type.String()), Type.Null()]); /** * The config of one scenario feature, see features.ts. @@ -26,7 +27,7 @@ export class FeatureRecord { /** Name of the feature in features.ts */ public id: string, public enabled: boolean, - public values: Record = {}, + public values: Record = {}, public updatedAtMs: number = 0, public isDeleted: boolean = false, ) {} @@ -117,7 +118,7 @@ export class FeatureUpdateRequest { constructor( public id: string, public enabled: boolean, - public values: Record, + public values: Record, ) {} static jsonSchema = Type.Object({ diff --git a/exp_scenario/module/config/afk_kick.lua b/exp_scenario/module/config/afk_kick.lua deleted file mode 100644 index d961897c..00000000 --- a/exp_scenario/module/config/afk_kick.lua +++ /dev/null @@ -1,18 +0,0 @@ ---- Settings for kicking players when everyone online is afk --- @config AFK-Kick - -local Features = require("modules/exp_scenario/features") -local Roles = require("modules/exp_roles") - -return Features.config("afk_kick", { - admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time - trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time - afk_minutes = 10, --- @setting afk_minutes The time in minutes that must pass for a player to be considered afk - kick_minutes = 30, --- @setting kick_minutes The time in minutes that must pass without any active players for all players to be kicked - trust_minutes = 600, --- @setting trust_minutes The time in minutes that a player must be online for to count as trusted - update_seconds = 1800, --- @setting update_seconds How often in seconds the script checks for active players - custom_active_check = function(player) - local veteran = Roles.get_role_by_name("Veteran") - return veteran ~= nil and not Roles.get_player_highest_role(player):is_lower_than(veteran) - end, -}) diff --git a/exp_scenario/module/config/chat_auto_reply.lua b/exp_scenario/module/config/chat_auto_reply.lua index 9d1c9799..344d0fc2 100644 --- a/exp_scenario/module/config/chat_auto_reply.lua +++ b/exp_scenario/module/config/chat_auto_reply.lua @@ -67,7 +67,6 @@ return Features.config("chat_auto_reply", { }, allow_command_prefix_for_messages = true, --- @setting allow_command_prefix_for_messages when true any message trigger will print to all player when prefixed command_admin_only = false, --- @setting command_admin_only when true will only allow chat commands for admins - command_permission = "exp_scenario.chat.commands", --- @setting command_permission the permission used to allow command prefixes command_prefix = "!", --- @setting command_prefix prefix used for commands below and to print to all players (if enabled above) --- @type table commands = { --- @setting commands will trigger only when command prefix is given diff --git a/exp_legacy/module/config/popup_messages.lua b/exp_scenario/module/config/popups.lua similarity index 74% rename from exp_legacy/module/config/popup_messages.lua rename to exp_scenario/module/config/popups.lua index 3a098d0e..60471cad 100644 --- a/exp_legacy/module/config/popup_messages.lua +++ b/exp_scenario/module/config/popups.lua @@ -1,10 +1,12 @@ ---- A combination of config settings for different popup values like chat and damage --- @config Popup-Messages +--- Popups for chat and damage, shared by control/chat_popup.lua and control/damage_popups.lua +-- @config Popups -return { +local Features = require("modules/exp_scenario/features") + +return Features.config("popups", { show_player_messages = true, --- @setting show_player_messages weather a message in chat will make a popup above them show_player_mentions = true, --- @setting show_player_mentions weather a mentioned player will have a popup when mentioned in chat show_player_damage = true, --- @setting show_player_damage weather to show damage done by players show_player_health = true, --- @setting show_player_health weather to show player health when attacked damage_location_variance = 0.8, --- @setting damage_location_variance how close to the eade of an entity the popups will appear -} +}) diff --git a/exp_legacy/module/config/spawn_area.lua b/exp_scenario/module/config/spawn_area.lua similarity index 99% rename from exp_legacy/module/config/spawn_area.lua rename to exp_scenario/module/config/spawn_area.lua index 72ee8c8e..aaca027c 100644 --- a/exp_legacy/module/config/spawn_area.lua +++ b/exp_scenario/module/config/spawn_area.lua @@ -1,7 +1,9 @@ --- Used to config the spawn generation settings yes there is alot here i know just ignore the long tables at the end (they were generated with a command) -- @config Spawn-Area -return { +local Features = require("modules/exp_scenario/features") + +return Features.config("spawn_area", { spawn_area = { --- @setting spawn_area Settings relating to the whole spawn area -- Enable predefined patches: 128, else: 32 deconstruction_radius = 20, -- @setting deconstruction_radius All entities within this radius will be removed @@ -12,7 +14,6 @@ return { turrets = { --- @setting turrets Settings relating to adding turrets to spawn enabled = true, --- @setting enabled Whether turrets will be added to spawn ammo_type = "uranium-rounds-magazine", --- @setting ammo_type The ammo type that will be used during refills - refill_time = 60 * 60 * 5, --- @setting refill_time The time in ticks between each refill of the turrets, only change if having lag issues offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to turrets locations = { --- @setting locations The locations of all turrets, this list can change during runtime { -3, -3 }, @@ -242,7 +243,6 @@ return { resource_refill_nearby = { enabled = false, range = 128, - refill_time = 36000, resources_name = { "iron-ore", "copper-ore", @@ -252,4 +252,4 @@ return { }, amount = { 2500, 4000 }, }, -} +}) diff --git a/exp_scenario/module/control/afk_kick.lua b/exp_scenario/module/control/afk_kick.lua index 926ef446..c7f4b8e8 100644 --- a/exp_scenario/module/control/afk_kick.lua +++ b/exp_scenario/module/control/afk_kick.lua @@ -4,8 +4,16 @@ Kicks players when all players on the server are afk local Async = require("modules/exp_util/async") local Features = require("modules/exp_scenario/features") +local Roles = require("modules/exp_roles") local Storage = require("modules/exp_util/storage") -local config = require("modules/exp_scenario/config/afk_kick") +local config = Features.config("afk_kick", { + admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time + trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time + active_role_id = Features.optional("number"), --- @setting active_role_id Players with this role or higher are treated as active regardless of afk time + afk_minutes = 10, --- @setting afk_minutes The time in minutes that must pass for a player to be considered afk + kick_minutes = 30, --- @setting kick_minutes The time in minutes that must pass without any active players for all players to be kicked + trust_minutes = 600, --- @setting trust_minutes The time in minutes that a player must be online for to count as trusted +}) local ticks_per_minute = 3600 @@ -22,13 +30,21 @@ local afk_kick_player_async = game.kick_player(player, "AFK while no active players on the server") end) +--- Check if a player has a role which counts as active regardless of afk time +--- @param player LuaPlayer +--- @return boolean +local function is_active_role(player) + local role = config.active_role_id and Roles.get_role(config.active_role_id) + return role ~= nil and not Roles.get_player_highest_role(player):is_lower_than(role) +end + --- Check if there is an active player local function has_active_player() for _, player in ipairs(game.connected_players) do if player.afk_time < config.afk_minutes * ticks_per_minute or config.admin_as_active and player.admin or config.trust_as_active and player.online_time > config.trust_minutes * ticks_per_minute - or config.custom_active_check and config.custom_active_check(player) then + or is_active_role(player) then script_data.last_active = game.tick return true end @@ -80,8 +96,8 @@ return Features.guard(config, { events = { [e.on_player_joined_game] = on_player_joined_game, }, - intervals = { - update_seconds = check_afk_players, + on_nth_tick = { + [60 * 60 * 30] = check_afk_players, }, has_active_player = has_active_player, }) diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index e3d23092..a8f00392 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -21,7 +21,7 @@ local function on_console_chat(event) -- Check if the player can chat commands local commands_allowed = true if config.command_admin_only and not player.admin then commands_allowed = false end - if config.command_permission and not Roles.player_has_permission(player, config.command_permission) then commands_allowed = false end + if not Roles.player_has_permission(player, "exp_scenario.chat.commands") then commands_allowed = false end -- Check if a key word appears in the message for key_word, reply in pairs(config.messages) do diff --git a/exp_scenario/module/control/chat_popup.lua b/exp_scenario/module/control/chat_popup.lua index a858acf3..b7da1600 100644 --- a/exp_scenario/module/control/chat_popup.lua +++ b/exp_scenario/module/control/chat_popup.lua @@ -3,7 +3,8 @@ Creates flying text entities when a player sends a message in chat ]] local FlyingText = require("modules/exp_util/flying_text") -local config = require("modules.exp_legacy.config.popup_messages") +local Features = require("modules/exp_scenario/features") +local config = require("modules/exp_scenario/config/popups") local lower = string.lower local find = string.find @@ -41,8 +42,8 @@ end local e = defines.events -return { +return Features.guard(config, { events = { [e.on_console_chat] = on_console_chat, - } -} + }, +}) diff --git a/exp_scenario/module/control/damage_popups.lua b/exp_scenario/module/control/damage_popups.lua index 803f95b6..e801ae42 100644 --- a/exp_scenario/module/control/damage_popups.lua +++ b/exp_scenario/module/control/damage_popups.lua @@ -4,7 +4,8 @@ also shows player health when a player is attacked ]] local FlyingText = require("modules/exp_util/flying_text") -local config = require("modules.exp_legacy.config.popup_messages") +local Features = require("modules/exp_scenario/features") +local config = require("modules/exp_scenario/config/popups") local random = math.random local floor = math.floor @@ -44,8 +45,8 @@ end local e = defines.events -return { +return Features.guard(config, { events = { [e.on_entity_damaged] = on_entity_damaged, }, -} +}) diff --git a/exp_scenario/module/control/death_markers.lua b/exp_scenario/module/control/death_markers.lua index cbe42a65..a6d1e4a1 100644 --- a/exp_scenario/module/control/death_markers.lua +++ b/exp_scenario/module/control/death_markers.lua @@ -14,7 +14,6 @@ local config = Features.config("death_markers", { map_icon = nil, --- @setting map_icon the icon that the map marker shows; nil means no icon; format as a SingleID show_light_at_corpse = true, --- @setting show_light_at_corpse if a light should be rendered at the corpse show_line_to_corpse = true, --- @setting show_line_to_corpse if a line should be rendered from you to your corpse - check_map_tags_seconds = 300, --- @setting check_map_tags_seconds seconds between checks for missing map markers }) local map_tag_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true } @@ -149,8 +148,8 @@ end local e = defines.events return Features.guard(config, { - intervals = { - check_map_tags_seconds = check_map_tags, + on_nth_tick = { + [60 * 60 * 5] = check_map_tags, }, events = { [e.on_player_died] = on_player_died, diff --git a/exp_scenario/module/control/discord_alerts.lua b/exp_scenario/module/control/discord_alerts.lua index 6b700341..07cc7a39 100644 --- a/exp_scenario/module/control/discord_alerts.lua +++ b/exp_scenario/module/control/discord_alerts.lua @@ -4,7 +4,43 @@ Sends alert messages to our discord server when certain events are triggered local ExpUtil = require("modules/exp_util") local Colors = require("modules/exp_util/include/color") -local config = require("modules.exp_legacy.config.discord_alerts") +local Features = require("modules/exp_scenario/features") +local EntityProtection = require("modules/exp_scenario/control/protection") +local Jail = require("modules/exp_scenario/control/jail") + +local config = Features.config("discord_alerts", { + show_playtime = true, --- @setting show_playtime Add the playtime of players to alerts + entity_protection = true, --- @setting entity_protection Alert when a player repeatedly removes protected entities + player_bans = true, --- @setting player_bans Alert when a player is banned or unbanned + player_mutes = true, --- @setting player_mutes Alert when a player is muted or unmuted + player_kicks = true, --- @setting player_kicks Alert when a player is kicked + player_promotes = false, --- @setting player_promotes Alert when a player is promoted or demoted + player_jail = true, --- @setting player_jail Alert when a player is jailed or unjailed + logged_commands = { --- @setting logged_commands Alert when a player uses one of these commands + "config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat", + }, +}) + +--- Set of the logged commands, rebuilt when the config changes +local logged_commands = {} --- @type table +Features.on_apply(config, function() + logged_commands = {} + for _, command in ipairs(config.logged_commands) do + logged_commands[command] = true + end +end) + +--- Wrap an event handler so it only runs while the alert is enabled +--- @param setting string +--- @param handler fun(event: table) +--- @return fun(event: table) +local function when(setting, handler) + return function(event) + if config[setting] then + return handler(event) + end + end +end local format_string = string.format local write_json = ExpUtil.write_json @@ -80,172 +116,158 @@ local function emit_event(opts) end --- Repeated protected entity mining -if config.entity_protection then - local EntityProtection = require("modules/exp_scenario/control/protection") - events[EntityProtection.on_repeat_violation] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Entity Protection", - description = "A player removed protected entities", - color = Colors.yellow, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "Entity", inline = true, value = event.entity.name }, - { name = "Location", value = format_string("X %.1f Y %.1f", event.entity.position.x, event.entity.position.y) }, - }, - } - end -end +events[EntityProtection.on_repeat_violation] = when("entity_protection", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Entity Protection", + description = "A player removed protected entities", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "Entity", inline = true, value = event.entity.name }, + { name = "Location", value = format_string("X %.1f Y %.1f", event.entity.position.x, event.entity.position.y) }, + }, + } +end) --- When a player is jailed or unjailed -if config.player_jail then - local Jail = require("modules/exp_scenario/control/jail") - events[Jail.on_player_jailed] = function(event) - local player_name, by_player_name = get_player_name(event) +events[Jail.on_player_jailed] = when("player_jail", function(event) + local player_name, by_player_name = get_player_name(event) + emit_event{ + title = "Jail", + description = "A player has been jailed", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Reason", value = event.reason }, + }, + } +end) +events[Jail.on_player_unjailed] = when("player_jail", function(event) + local player_name, by_player_name = get_player_name(event) + emit_event{ + title = "Unjail", + description = "A player has been unjailed", + color = Colors.green, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "By", inline = true, value = append_playtime(by_player_name) }, + }, + } +end) + +--- Ban and unban +--- @param event EventData.on_player_banned +events[e.on_player_banned] = when("player_bans", function(event) + if event.by_player then + local by_player = game.players[event.by_player] emit_event{ - title = "Jail", - description = "A player has been jailed", - color = Colors.yellow, + title = "Banned", + description = "A player has been banned", + color = Colors.red, fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Player", inline = true, value = append_playtime(event.player_name) }, + { name = "By", inline = true, value = append_playtime(by_player.name) }, { name = "Reason", value = event.reason }, }, } end - events[Jail.on_player_unjailed] = function(event) - local player_name, by_player_name = get_player_name(event) +end) +--- @param event EventData.on_player_unbanned +events[e.on_player_unbanned] = when("player_bans", function(event) + if event.by_player then + local by_player = game.players[event.by_player] emit_event{ - title = "Unjail", - description = "A player has been unjailed", + title = "Un-Banned", + description = "A player has been un-banned", color = Colors.green, fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Player", inline = true, value = append_playtime(event.player_name) }, + { name = "By", inline = true, value = append_playtime(by_player.name) }, + { name = "Reason", value = event.reason }, }, } end -end - ---- Ban and unban -if config.player_bans then - --- @param event EventData.on_player_banned - events[e.on_player_banned] = function(event) - if event.by_player then - local by_player = game.players[event.by_player] - emit_event{ - title = "Banned", - description = "A player has been banned", - color = Colors.red, - fields = { - { name = "Player", inline = true, value = append_playtime(event.player_name) }, - { name = "By", inline = true, value = append_playtime(by_player.name) }, - { name = "Reason", value = event.reason }, - }, - } - end - end - --- @param event EventData.on_player_unbanned - events[e.on_player_unbanned] = function(event) - if event.by_player then - local by_player = game.players[event.by_player] - emit_event{ - title = "Un-Banned", - description = "A player has been un-banned", - color = Colors.green, - fields = { - { name = "Player", inline = true, value = append_playtime(event.player_name) }, - { name = "By", inline = true, value = append_playtime(by_player.name) }, - { name = "Reason", value = event.reason }, - }, - } - end - end -end +end) --- Mute and unmute -if config.player_mutes then - --- @param event EventData.on_player_muted - events[e.on_player_muted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Muted", - description = "A player has been muted", - color = Colors.yellow, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end - --- @param event EventData.on_player_unmuted - events[e.on_player_unmuted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Un-Muted", - description = "A player has been un-muted", - color = Colors.green, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end -end +--- @param event EventData.on_player_muted +events[e.on_player_muted] = when("player_mutes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Muted", + description = "A player has been muted", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) +--- @param event EventData.on_player_unmuted +events[e.on_player_unmuted] = when("player_mutes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Un-Muted", + description = "A player has been un-muted", + color = Colors.green, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) --- Kick -if config.player_kicks then - --- @param event EventData.on_player_kicked - events[e.on_player_kicked] = function(event) - if event.by_player then - local player_name = get_player_name(event) - local by_player = game.players[event.by_player] - emit_event{ - title = "Kick", - description = "A player has been kicked", - color = Colors.orange, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(by_player.name) }, - { name = "Reason", value = event.reason }, - }, - } - end +--- @param event EventData.on_player_kicked +events[e.on_player_kicked] = when("player_kicks", function(event) + if event.by_player then + local player_name = get_player_name(event) + local by_player = game.players[event.by_player] + emit_event{ + title = "Kick", + description = "A player has been kicked", + color = Colors.orange, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "By", inline = true, value = append_playtime(by_player.name) }, + { name = "Reason", value = event.reason }, + }, + } end -end +end) --- Promote and demote -if config.player_promotes then - --- @param event EventData.on_player_promoted - events[e.on_player_promoted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Promote", - description = "A player has been promoted", - color = Colors.green, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end - --- @param event EventData.on_player_demoted - events[e.on_player_demoted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Demote", - description = "A player has been demoted", - color = Colors.yellow, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end -end +--- @param event EventData.on_player_promoted +events[e.on_player_promoted] = when("player_promotes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Promote", + description = "A player has been promoted", + color = Colors.green, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) +--- @param event EventData.on_player_demoted +events[e.on_player_demoted] = when("player_promotes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Demote", + description = "A player has been demoted", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) --- @param event EventData.on_console_command events[e.on_console_command] = function(event) if event.player_index then local player_name = get_player_name(event) - if config[event.command] then + if logged_commands[event.command] then emit_event{ title = event.command:gsub("^%l", string.upper), description = "/" .. event.command .. " was used", @@ -259,6 +281,6 @@ events[e.on_console_command] = function(event) end end -return { +return Features.guard(config, { events = events, -} +}) diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index ba8f4320..2117fa70 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -3,16 +3,37 @@ Disable new players from having certain items in their inventory, most commonly ]] local ExpUtil = require("modules/exp_util") +local Features = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.nukeprotect") + +local config = Features.config("nuke_protection", { + ignore_admins = true, --- @setting ignore_admins Admins can hold banned items + banned_items = { "atomic-bomb" }, --- @setting banned_items Items which are removed from the inventory of players without the bypass permission +}) + +--- Set of the banned items, rebuilt when the config changes +local banned_items = {} --- @type table +Features.on_apply(config, function() + banned_items = {} + for _, item_name in ipairs(config.banned_items) do + banned_items[item_name] = true + end +end) + +--- The inventories which are checked and the events which trigger the check +local inventories = { + [defines.events.on_player_ammo_inventory_changed] = defines.inventory.character_ammo, + [defines.events.on_player_armor_inventory_changed] = defines.inventory.character_armor, + [defines.events.on_player_gun_inventory_changed] = defines.inventory.character_guns, + [defines.events.on_player_main_inventory_changed] = defines.inventory.character_main, +} --- Check all items in the given inventory --- @param player LuaPlayer --- @param type defines.inventory ---- @param banned_items string[] -local function check_items(player, type, banned_items) +local function check_items(player, type) -- If the player has perms to be ignored, then they should be - if config.ignore_permission and Roles.player_has_permission(player, config.ignore_permission) then return end + if Roles.player_has_permission(player, "exp_scenario.bypass.nuke_protection") then return end if config.ignore_admins and player.admin then return end local items = {} --- @type LuaItemStack[] @@ -37,20 +58,16 @@ end --- Add event handlers for the different inventories local events = {} -for index, inventory in ipairs(config.inventories) do - if next(inventory.items) then - local assert_msg = "invalid event, no player index, index: " .. index - --- @param event { player_index: number } - events[inventory.event] = function(event) - local player_index = assert(event.player_index, assert_msg) - local player = assert(game.get_player(player_index)) - if player and player.valid then - check_items(player, inventory.inventory, inventory.items) - end +for event_id, inventory in pairs(inventories) do + --- @param event { player_index: number } + events[event_id] = function(event) + local player = assert(game.get_player(event.player_index)) + if player.valid then + check_items(player, inventory) end end end -return { +return Features.guard(config, { events = events, -} +}) diff --git a/exp_scenario/module/control/protection.lua b/exp_scenario/module/control/protection.lua index 9839290f..aa8d358e 100644 --- a/exp_scenario/module/control/protection.lua +++ b/exp_scenario/module/control/protection.lua @@ -3,8 +3,22 @@ Protects entities and areas from being mined by players other than the one who p ]] local Storage = require("modules/exp_util/storage") +local Features = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.protection") + +local config = Features.config("protection", { + ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter + repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_minutes in order to trigger repeated removal protection + repeat_minutes = 20, --- @setting repeat_minutes The length of time, in minutes, that protected removals will be remembered for + always_protected_names = {}, --- @setting always_protected_names Names of entities which are always protected + always_protected_types = { --- @setting always_protected_types Types of entities which are always protected + "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", + }, + always_trigger_repeat_names = {}, --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection + always_trigger_repeat_types = { --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection + "reactor", "fusion-reactor", "rocket-silo", + }, +}) local format_string = string.format local floor = math.floor @@ -19,10 +33,10 @@ local Protection = { on_repeat_violation = script.generate_event_name(), --- Names of entities which are always protected --- @type string[] - protected_entity_names = config.always_protected_names, + protected_entity_names = {}, --- Types of entities which are always protected --- @type string[] - protected_entity_types = config.always_protected_types, + protected_entity_types = {}, --- @package events = {}, --- @package @@ -46,10 +60,18 @@ local function to_set(values) return set end -local always_protected_names = to_set(config.always_protected_names) -local always_protected_types = to_set(config.always_protected_types) -local always_trigger_repeat_names = to_set(config.always_trigger_repeat_names) -local always_trigger_repeat_types = to_set(config.always_trigger_repeat_types) +local always_protected_names = {} --- @type table +local always_protected_types = {} --- @type table +local always_trigger_repeat_names = {} --- @type table +local always_trigger_repeat_types = {} --- @type table +Features.on_apply(config, function() + Protection.protected_entity_names = config.always_protected_names + Protection.protected_entity_types = config.always_protected_types + always_protected_names = to_set(config.always_protected_names) + always_protected_types = to_set(config.always_protected_types) + always_trigger_repeat_names = to_set(config.always_trigger_repeat_names) + always_trigger_repeat_types = to_set(config.always_trigger_repeat_types) +end) local protected_entities = {} --- @type table> Keyed by surface index then entity key local protected_areas = {} --- @type table> Keyed by surface index then area key @@ -168,7 +190,7 @@ end local function is_ignored(player, entity) if config.ignore_admins and player.admin then return true end if entity.last_user == nil or entity.last_user.index == player.index then return true end - if config.ignore_permission and Roles.player_has_permission(player, config.ignore_permission) then return true end + if Roles.player_has_permission(player, "exp_scenario.bypass.entity_protection") then return true end return false end @@ -199,8 +221,10 @@ end local function on_pre_player_mined_item(event) local entity = event.entity local player = game.players[event.player_index] + -- Protected entities are still forgotten while disabled, only the violations stop if - not is_ignored(player, entity) + config.enabled + and not is_ignored(player, entity) and (Protection.is_entity_protected(entity) or Protection.is_position_protected(entity.surface, entity.position)) then raise_violation(event, player) @@ -217,7 +241,7 @@ end --- Forget protected removals older than the repeat lifetime local function clear_old_repeats() - local old = game.tick - config.repeat_lifetime + local old = game.tick - config.repeat_minutes * 3600 for player_name, player_repeats in pairs(repeats) do if player_repeats.last <= old then repeats[player_name] = nil @@ -232,6 +256,6 @@ Protection.events[e.on_space_platform_pre_mined] = on_entity_removed Protection.events[e.on_robot_pre_mined] = on_entity_removed Protection.events[e.on_entity_died] = on_entity_removed Protection.events[e.script_raised_destroy] = on_entity_removed -Protection.on_nth_tick[config.refresh_rate] = clear_old_repeats +Protection.on_nth_tick[3600 * 5] = clear_old_repeats return Protection diff --git a/exp_scenario/module/control/spawn_area.lua b/exp_scenario/module/control/spawn_area.lua index d4c23275..3eba435c 100644 --- a/exp_scenario/module/control/spawn_area.lua +++ b/exp_scenario/module/control/spawn_area.lua @@ -2,7 +2,8 @@ Adds a custom spawn area with chests and afk turrets ]] -local config = require("modules.exp_legacy.config.spawn_area") +local Features = require("modules/exp_scenario/features") +local config = require("modules/exp_scenario/config/spawn_area") --- Apply an offset to a LuaPosition --- @param position MapPosition.struct @@ -251,30 +252,26 @@ end local on_nth_tick = {} -if config.turrets.enabled then - --- Refill the ammo in the spawn turrets - on_nth_tick[config.turrets.refill_time] = function() - if game.tick < 10 then return end - update_turrets() - end +--- Refill the ammo in the spawn turrets +on_nth_tick[60 * 60 * 5] = function() + if game.tick < 10 or not config.turrets.enabled then return end + update_turrets() end -if config.resource_refill_nearby.enabled then - --- - on_nth_tick[config.resource_refill_nearby.refill_time] = function() - if game.tick < 10 then return end +--- Refill the resources near spawn +on_nth_tick[60 * 60 * 10] = function() + if game.tick < 10 or not config.resource_refill_nearby.enabled then return end - local force = game.forces.player - local surface = assert(game.get_surface("nauvis")) - local entities = surface.find_entities_filtered{ - position = force.get_spawn_position(surface), - radius = config.resource_refill_nearby.range, - name = config.resource_refill_nearby.resources_name - } + local force = game.forces.player + local surface = assert(game.get_surface("nauvis")) + local entities = surface.find_entities_filtered{ + position = force.get_spawn_position(surface), + radius = config.resource_refill_nearby.range, + name = config.resource_refill_nearby.resources_name + } - for _, ore in ipairs(entities) do - ore.amount = ore.amount + math.random(config.resource_refill_nearby.amount[1], config.resource_refill_nearby.amount[2]) - end + for _, ore in ipairs(entities) do + ore.amount = ore.amount + math.random(config.resource_refill_nearby.amount[1], config.resource_refill_nearby.amount[2]) end end @@ -302,9 +299,9 @@ end local e = defines.events -return { +return Features.guard(config, { on_nth_tick = on_nth_tick, events = { [e.on_player_created] = on_player_created, - } -} + }, +}) diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua index 31927a56..081d955a 100644 --- a/exp_scenario/module/features.lua +++ b/exp_scenario/module/features.lua @@ -4,23 +4,23 @@ Features which the controller can enable, disable, and configure while the game A feature's config is the table returned by Features.config. The controller sends overrides over rcon, these are kept in storage and copied onto that same table, so read values when they are used rather than copying them into locals when the -file loads. Only boolean, number, and string defaults can be overridden. +file loads. Values derived from the config can be rebuilt with Features.on_apply. -Values used while the file loads can not change without a restart. Periods can -use intervals instead of on_nth_tick, everything else such as toolbar sprites -stays as a plain value and is not listed in features.ts. +Booleans, numbers, strings, and lists of strings can be overridden, including +those in nested tables which are addressed as "section.key". Use Features.optional +for a value which has no default. Values used while the file loads, such as +on_nth_tick periods, stay as plain values and are not listed in features.ts. --- Declaring a feature with config, small configs live at the top of the file which uses them: local Features = require("modules/exp_scenario/features") local config = Features.config("my_feature", { show_message = true, - check_seconds = 60, + role_id = Features.optional("number"), }) --- Handlers do nothing while the feature is disabled, a feature without config can be guarded by name: return Features.guard(config, { events = { [defines.events.on_player_joined_game] = on_player_joined_game }, - intervals = { check_seconds = check_players }, -- Runs every config.check_seconds seconds }) --- Toolbar buttons are hidden while disabled, which also hides their left element: @@ -42,40 +42,78 @@ local Features = { }, } -local overridable = { - boolean = true, - number = true, - string = true, -} - --- @class ExpScenario.FeatureConfig --- @field enabled boolean --- @alias ExpScenario.Feature string | ExpScenario.FeatureConfig +--- @class ExpScenario.FeatureField +--- @field parent table The table holding the value within the config +--- @field key string The key of the value within its parent +--- @field default any +--- @field type string "boolean", "number", "string", "list" of strings, or a type which can not be overridden + +--- @class ExpScenario.Optional +--- @field __feature_optional string + --- The live config tables by feature name local features = {} --- @type table ---- Copies of the defaults by feature name, used to undo overrides -local defaults = {} --- @type table> +--- The values of each feature by feature name then path +local fields = {} --- @type table> ---- Overrides received from the controller by feature name -local overrides = {} --- @type table> +--- Called after a feature is applied by feature name +local on_apply = {} --- @type table + +--- Overrides received from the controller by feature name then path +local overrides = {} --- @type table> + +--- Check if a table is a list, empty tables count as lists +--- @param tbl table +--- @return boolean +local function is_list(tbl) + return next(tbl) == nil or tbl[1] ~= nil +end + +--- Check if a table is a list of strings, empty tables count as lists of strings +--- @param tbl table +--- @return boolean +local function is_string_list(tbl) + if not is_list(tbl) then return false end + for _, item in pairs(tbl) do + if type(item) ~= "string" then return false end + end + return true +end + +--- Check if an override can replace a field +--- @param field ExpScenario.FeatureField +--- @param value any +--- @return boolean +local function is_valid(field, value) + if field.type == "list" then + return type(value) == "table" and is_string_list(value) + end + return type(value) == field.type and (field.type == "boolean" or field.type == "number" or field.type == "string") +end --- Reset a feature to its defaults and apply its overrides --- @param name string local function apply(name) - local config = features[name] - local feature_defaults = defaults[name] - for key, value in pairs(feature_defaults) do - config[key] = value + local feature_overrides = overrides[name] or {} + for path, field in pairs(fields[name]) do + local value = feature_overrides[path] + if value == nil then + value = field.default + elseif not is_valid(field, value) then + log("[WARNING] Invalid override for " .. name .. "." .. path .. ", using the default") + value = field.default + end + field.parent[field.key] = value end - for key, value in pairs(overrides[name] or {}) do - local default = feature_defaults[key] - if default ~= nil and type(value) == type(default) and overridable[type(value)] then - config[key] = value - end + for _, callback in ipairs(on_apply[name]) do + callback(features[name]) end end @@ -86,6 +124,42 @@ Storage.register(overrides, function(tbl) end end) +--- Mark a value as having no default, the argument is the type it can be overridden with +--- @param value_type "boolean" | "number" | "string" | "list" +--- @return any +function Features.optional(value_type) + return { __feature_optional = value_type } +end + +--- Collect the values of a config table, recursing into nested tables which are not lists +--- @param feature_fields table +--- @param tbl table +--- @param prefix string +local function collect_fields(feature_fields, tbl, prefix) + for key, value in pairs(tbl) do + if type(key) == "string" then + local field = { parent = tbl, key = key, default = value, type = type(value) } + if type(value) == "table" then + if value.__feature_optional then + field.default = nil + field.type = value.__feature_optional + tbl[key] = nil + elseif is_string_list(value) then + field.type = "list" + elseif is_list(value) then + field.type = "table" + else + collect_fields(feature_fields, value, prefix .. key .. ".") + field = nil + end + end + if field then + feature_fields[prefix .. key] = field + end + end + end +end + --- Declare a feature and its config, features are enabled unless the config says otherwise --- @generic T : table --- @param name string @@ -98,13 +172,12 @@ function Features.config(name, config) config.enabled = true end - local feature_defaults = {} - for key, value in pairs(config) do - feature_defaults[key] = value - end + local feature_fields = {} + collect_fields(feature_fields, config, "") features[name] = config - defaults[name] = feature_defaults + fields[name] = feature_fields + on_apply[name] = {} return config end @@ -118,6 +191,21 @@ local function resolve(feature) return features[feature] or error("Unknown feature: " .. tostring(feature), 3) end +--- Get the name of a feature from its config, or return the name given +--- @param feature ExpScenario.Feature +--- @return string +local function resolve_name(feature) + if type(feature) == "string" then + return feature + end + for name, config in pairs(features) do + if config == feature then + return name + end + end + error("Unknown feature config", 3) +end + --- Check if a feature is enabled --- @param feature ExpScenario.Feature --- @return boolean @@ -125,9 +213,18 @@ function Features.is_enabled(feature) return resolve(feature).enabled end ---- Make the handlers of an event handler lib do nothing while the feature is disabled --- A name which has not been declared is declared without config. The intervals field --- maps config keys to handlers, each runs every config[key] seconds, checked once a second +--- Call a function now and whenever the config of a feature changes, used to rebuild values derived from it +-- Also called during on_load, so it must only change locals and not the game state +--- @param feature ExpScenario.Feature +--- @param callback fun(config: any) +function Features.on_apply(feature, callback) + local name = resolve_name(feature) + table.insert(on_apply[name], callback) + callback(features[name]) +end + +--- Make the event and on_nth_tick handlers of an event handler lib do nothing while the feature is disabled +-- A name which has not been declared is declared without config --- @generic T : table --- @param feature ExpScenario.Feature --- @param lib T @@ -138,25 +235,6 @@ function Features.guard(feature, lib) end local config = resolve(feature) - local intervals = lib.intervals - if intervals then - lib.intervals = nil - lib.on_nth_tick = lib.on_nth_tick or {} - local every_second = lib.on_nth_tick[60] - lib.on_nth_tick[60] = function(event) - if every_second then - every_second(event) - end - -- Exactly one check each period lands within its first second - local tick = event.tick - for key, handler in pairs(intervals) do - if tick % (config[key] * 60) < 60 then - handler(event) - end - end - end - end - for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do for key, handler in pairs(handlers) do handlers[key] = function(event) @@ -173,17 +251,21 @@ end --- @class ExpScenario.FeatureUpdate --- @field name string --- @field enabled boolean ---- @field values table +--- @field values table --- Called over rcon by the instance plugin with the features which changed, unknown features are ignored --- @param updates ExpScenario.FeatureUpdate[] function Features.receive_update(updates) for _, update in ipairs(updates) do local name = update.name - if features[name] then + local feature_fields = fields[name] + if feature_fields then local override = { enabled = update.enabled } - for key, value in pairs(update.values) do - override[key] = value + for path, value in pairs(update.values) do + if feature_fields[path] == nil then + log("[WARNING] Unknown override for " .. name .. "." .. path) + end + override[path] = value end overrides[name] = override diff --git a/exp_scenario/test/features.test.js b/exp_scenario/test/features.test.js index c33c8049..2e3bf640 100644 --- a/exp_scenario/test/features.test.js +++ b/exp_scenario/test/features.test.js @@ -16,10 +16,35 @@ function luaFiles(dir, prefix = "") { const moduleFiles = luaFiles(path.join(import.meta.dirname, "..", "module")); -/** Read a literal default from a Features.config call, only covers single line booleans, numbers, and strings. */ -function luaDefault(source, key) { - const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"),`, "m").exec(source); - return match ? JSON.parse(match[1]) : undefined; +/** + * Read a default from a Features.config call, a path of "a.b" looks for b after the table a starts. + * Only covers booleans, numbers, strings, lists of strings, and Features.optional. + */ +function luaDefault(source, path) { + const keys = path.split("."); + let start = 0; + for (const section of keys.slice(0, -1)) { + const match = new RegExp(`^\\s*${section} = \\{`, "m").exec(source.slice(start)); + if (!match) { + return undefined; + } + start += match.index + match[0].length; + } + + const key = keys.at(-1); + const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Features\\.optional\\("\\w+"\\)|\\{[^{}]*\\})`, "m") + .exec(source.slice(start)); + if (!match) { + return undefined; + } + const literal = match[1]; + if (literal.startsWith("Features.optional")) { + return null; + } + if (literal.startsWith("{")) { + return [...literal.matchAll(/"([^"]*)"/g)].map(item => item[1]); + } + return JSON.parse(literal); } t.test("features", t2 => { @@ -31,7 +56,7 @@ t.test("features", t2 => { t3.equal(declarations.length, 1, "one lua file declares the feature"); const source = declarations[0]?.[1] ?? ""; for (const field of feature.fields) { - t3.equal(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`); + t3.strictSame(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`); } t3.end(); }); @@ -62,5 +87,21 @@ t.test("validateFeatureValues()", t2 => { () => validateFeatureValues("afk_kick", { afk_minutes: Infinity }), { message: "Setting afk_minutes of afk_kick must be a finite number" }, ); + t2.throws( + () => validateFeatureValues("protection", { always_protected_types: ["boiler", 1] }), + { message: "Setting always_protected_types of protection must be a list of strings" }, + ); + t2.throws( + () => validateFeatureValues("afk_kick", { active_role_id: 1.5 }), + { message: "Setting active_role_id of afk_kick must be a role id or null" }, + ); + t2.same( + validateFeatureValues("protection", { always_protected_types: ["boiler"], always_trigger_repeat_types: ["reactor", "fusion-reactor", "rocket-silo"] }), + { always_protected_types: ["boiler"] }, + "lists equal to their default are dropped", + ); + t2.same(validateFeatureValues("afk_kick", { active_role_id: null }), {}, "no role is the default"); + t2.same(validateFeatureValues("afk_kick", { active_role_id: 3 }), { active_role_id: 3 }); + t2.same(validateFeatureValues("spawn_area", { "turrets.enabled": false }), { "turrets.enabled": false }, "nested settings"); t2.end(); }); diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua index b46a4e34..75d17960 100644 --- a/exp_scenario/test/module/env.lua +++ b/exp_scenario/test/module/env.lua @@ -17,10 +17,13 @@ local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type --- @field storage table The table registered with storage, as on_init would store it --- @field on_load fun(tbl: table) Act out on_load with the given storage table --- @field load_config fun(name: string): table Load module/config/.lua +--- @field logged string[] Messages passed to log return Framework.suite(function(env) --- @cast env ExpScenario.TestEnv local callback --- @type fun(tbl: table) + env.logged = {} + log = function(message) env.logged[#env.logged + 1] = message end env.extend_requires{ ["modules/exp_util/storage"] = { register = function(tbl, fn) diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua index 58de21b1..2c195d6d 100644 --- a/exp_scenario/test/module/features.lua +++ b/exp_scenario/test/module/features.lua @@ -81,35 +81,57 @@ Suite.test("guard() declares features by name", function(env) Suite.eq(env.Features.is_enabled("test"), false, "and can be looked up") end) -Suite.test("guard() runs intervals once per period of the config value in seconds", function(env) - local config = env.Features.config("test", { every = 5 }) - local ticks, seconds = {}, {} - local lib = env.Features.guard(config, { - on_nth_tick = { [60] = function(event) seconds[#seconds + 1] = event.tick end }, - intervals = { every = function(event) ticks[#ticks + 1] = event.tick end }, - }) - Suite.eq(lib.intervals, nil, "intervals are replaced by on_nth_tick") +Suite.test("receive_update() logs invalid and unknown overrides", function(env) + local config = env.Features.config("test", { flag = true }) + env.Features.receive_update{ { name = "test", enabled = true, values = { flag = 1, extra = 2 } } } + Suite.eq(config.flag, true, "the default is used") + Suite.eq(#env.logged, 2, "both are logged") +end) - local function run_until(last) - for tick = 60, last, 60 do lib.on_nth_tick[60]{ tick = tick } end - end +Suite.test("config() addresses nested values by path", function(env) + local offset = { 1, 2 } + local config = env.Features.config("test", { section = { flag = true, count = 1 }, offset = offset }) + env.Features.receive_update{ { name = "test", enabled = true, values = { ["section.flag"] = false, offset = { "x" } } } } + Suite.eq(config.section, { flag = false, count = 1 }, "the nested value changes in place") + Suite.eq(config.offset, offset, "lists of other types can not be overridden") - run_until(1200) - Suite.eq(ticks, { 300, 600, 900, 1200 }, "every 5 seconds") - Suite.eq(#seconds, 20, "an existing handler every second still runs") + env.Features.receive_update{ { name = "test", enabled = true, values = {} } } + Suite.eq(config.section.flag, true, "and goes back to its default") +end) - ticks = {} - env.Features.receive_update{ { name = "test", enabled = true, values = { every = 7 } } } - run_until(1260) - Suite.eq(ticks, { 420, 840, 1260 }, "the period can change at runtime") +Suite.test("config() allows lists of strings to be overridden", function(env) + local config = env.Features.config("test", { names = { "a", "b" }, empty = {} }) + env.Features.receive_update{ { name = "test", enabled = true, values = { names = { "c" }, empty = { "d" } } } } + Suite.eq(config.names, { "c" }, "a list is replaced") + Suite.eq(config.empty, { "d" }, "an empty default is a list") + env.Features.receive_update{ { name = "test", enabled = true, values = { names = { 1 } } } } + Suite.eq(config.names, { "a", "b" }, "a list with other types is invalid") +end) + +Suite.test("optional() declares a value without a default", function(env) + local config = env.Features.config("test", { role_id = env.Features.optional("number") }) + Suite.eq(config.role_id, nil, "nil until overridden") + env.Features.receive_update{ { name = "test", enabled = true, values = { role_id = 3 } } } + Suite.eq(config.role_id, 3, "overridden with the given type") + env.Features.receive_update{ { name = "test", enabled = true, values = {} } } + Suite.eq(config.role_id, nil, "and back to nil") +end) + +Suite.test("on_apply() is called now and after every change", function(env) + local config = env.Features.config("test", { names = { "a" } }) + local seen = {} + env.Features.on_apply("test", function(applied) seen[#seen + 1] = applied.names[1] end) + env.Features.receive_update{ { name = "test", enabled = true, values = { names = { "b" } } } } + env.on_load{ test = { enabled = true, names = { "c" } } } + Suite.eq(seen, { "a", "b", "c" }, "on register, update, and load") + Suite.eq(config.names, { "c" }, "after the config is applied") end) Suite.test("config files declare their feature", function(env) - env.extend_requires{ ["modules/exp_roles"] = {} } - local config = env.load_config("afk_kick") - env.Features.receive_update{ { name = "afk_kick", enabled = true, values = { afk_minutes = 3 } } } - Suite.eq(config.afk_minutes, 3, "afk_kick can be changed") - Suite.eq(config.kick_minutes, 30, "other values keep their default") + local config = env.load_config("popups") + env.Features.receive_update{ { name = "popups", enabled = true, values = { show_player_damage = false } } } + Suite.eq(config.show_player_damage, false, "popups can be changed") + Suite.eq(config.show_player_health, true, "other values keep their default") end) return Suite.run() diff --git a/exp_scenario/web/components/FeaturesPage.tsx b/exp_scenario/web/components/FeaturesPage.tsx index 7e134615..59f961a5 100644 --- a/exp_scenario/web/components/FeaturesPage.tsx +++ b/exp_scenario/web/components/FeaturesPage.tsx @@ -1,146 +1,323 @@ import React, { useContext, useState } from "react"; -import { Button, Card, Col, Empty, Form, Input, InputNumber, Row, Space, Switch, Typography, theme } from "antd"; +import { Button, Checkbox, Col, Divider, Input, InputNumber, Row, Select, Space, Switch } from "antd"; -import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount } from "@clusterio/web_ui"; +import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount, useRoles } from "@clusterio/web_ui"; -import { Feature, FeatureField, FeatureValue, features } from "../../features.js"; +import { Feature, FeatureField, FeatureValue, features, isDefaultValue } from "../../features.js"; import { FeatureRecord, FeatureUpdateRequest } from "../../messages.js"; import { useFeatures } from "../index"; -function FeatureListItem(props: { feature: Feature, enabled: boolean, selected: boolean, onClick: () => void }) { - const { token } = theme.useToken(); +type FeatureState = { enabled: boolean, values: Record }; +type PendingChanges = Record }>; + +// Same colours as the role and permission group pages +const borderColor = "#424242"; +const modifiedColor = "#2a1912"; + +function sameValue(a: FeatureValue | undefined, b: FeatureValue | undefined) { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, index) => item === b[index]); + } + return a === b; +} + +/** The stored state of a feature, with defaults for values which were not set. */ +function storedState(feature: Feature, record: FeatureRecord | undefined): FeatureState { + return { + enabled: record?.enabled ?? true, + values: { + ...Object.fromEntries(feature.fields.map(field => [field.name, field.default])), + ...record?.values, + }, + }; +} + +/** Group fields by the section in their name, "turrets.enabled" is in the "turrets" section. */ +function groupFields(fields: FeatureField[]) { + const groups = new Map(); + for (const field of fields) { + const section = field.name.includes(".") ? field.name.slice(0, field.name.lastIndexOf(".")) : ""; + if (!groups.has(section)) { + groups.set(section, []); + } + groups.get(section)!.push(field); + } + return [...groups.entries()]; +} + +/** Converts a section name into Title Case, same as the group titles on the role page */ +function formatSectionTitle(name: string) { + return name + .replace(/\./g, " / ") + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +function matches(text: string, query: string) { + return text.toLowerCase().includes(query); +} + +/** The fields of a feature which match the search, every field if the feature itself matches. */ +function searchFields(feature: Feature, query: string) { + if (!query || matches(feature.title, query) || matches(feature.description, query) || matches(feature.name, query)) { + return feature.fields; + } + return feature.fields.filter(field => ( + matches(field.title, query) || matches(field.description, query) || matches(field.name, query) + )); +} + +function RoleSelect(props: { value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) { + const [roles, synced] = useRoles(); + return onChange(e.target.value)} style={{ maxWidth: 400 }} />; + case "string_list": + return ; - } -} - -function defaultValues(feature: Feature) { - return Object.fromEntries(feature.fields.map(field => [field.name, field.default])); -} - -function FeatureForm(props: { feature: Feature, record: FeatureRecord | undefined }) { - const { feature, record } = props; - const control = useContext(ControlContext); - const account = useAccount(); - const [form] = Form.useForm(); - const [dirty, setDirty] = useState(false); - const [saving, setSaving] = useState(false); - const canEdit = account.hasPermission("exp_scenario.config.edit"); - - const initialValues = { - enabled: record?.enabled ?? true, - ...defaultValues(feature), - ...record?.values, - }; - - function save() { - const { enabled, ...values } = form.getFieldsValue() as { enabled: boolean } & Record; - setSaving(true); - control.send(new FeatureUpdateRequest(feature.name, enabled, values)) - .then(() => setDirty(false)) - .catch(notifyErrorHandler(`Error saving ${feature.title}`)) - .finally(() => setSaving(false)); - } - - return - {feature.title} - - {feature.description} - - } - > -
setDirty(true)} - onFinish={save} - > - - - - {feature.fields.map(field => - {fieldInput(field, !canEdit)} - )} - {canEdit && - - - } -
-
; -} - /** Enable, disable, and configure the scenario features listed in features.ts. */ export default function FeaturesPage() { + const control = useContext(ControlContext); + const account = useAccount(); const [records] = useFeatures(); - const [selected, setSelected] = useState(features[0]?.name); - const feature = features.find(f => f.name === selected); - const record = selected ? records.get(selected) : undefined; + const [pending, setPending] = useState({}); + const [search, setSearch] = useState(""); + const canEdit = Boolean(account.hasPermission("exp_scenario.config.edit")); + + const stored = Object.fromEntries(features.map(feature => [feature.name, storedState(feature, records.get(feature.name))])); + const current = (name: string): FeatureState => ({ + enabled: pending[name]?.enabled ?? stored[name].enabled, + values: { ...stored[name].values, ...pending[name]?.values }, + }); + + /** Record a change, dropping it again if it matches what is stored */ + function change(name: string, update: (entry: PendingChanges[string]) => void) { + setPending(prev => { + const entry = { enabled: prev[name]?.enabled, values: { ...prev[name]?.values } }; + update(entry); + if (entry.enabled === stored[name].enabled) { + delete entry.enabled; + } + for (const [key, value] of Object.entries(entry.values)) { + if (sameValue(value, stored[name].values[key])) { + delete entry.values[key]; + } + } + const next = { ...prev }; + if (entry.enabled === undefined && !Object.keys(entry.values).length) { + delete next[name]; + } else { + next[name] = entry; + } + return next; + }); + } + + function applyChanges() { + for (const name of Object.keys(pending)) { + const state = current(name); + control.send(new FeatureUpdateRequest(name, state.enabled, state.values)) + .then(() => setPending(prev => { + const next = { ...prev }; + delete next[name]; + return next; + })) + .catch(notifyErrorHandler(`Error saving ${features.find(f => f.name === name)?.title ?? name}`)); + } + } + + const query = search.trim().toLowerCase(); + const shown = features + .map(feature => ({ feature, fields: searchFields(feature, query) })) + .filter(({ feature, fields }) => !query || fields.length || matches(feature.title, query)); return - - - - {features.map(f => setSelected(f.name)} + + {Object.keys(pending).length > 0 &&
+ You have unsaved changes + + + + +
} + + setSearch(e.target.value)} style={{ marginBottom: 16 }} /> + + + + {/* Clear of the fixed page header */} +
+ {shown.map(({ feature }) => )} - +
- - {feature - // Remount on save from anywhere so the form shows the stored values - ? - : } + + + {shown.map(({ feature, fields }) => change(feature.name, entry => { entry.enabled = enabled; })} + onValue={(key, value) => change(feature.name, entry => { entry.values[key] = value; })} + onReset={() => change(feature.name, entry => { + for (const field of feature.fields) { + entry.values[field.name] = field.default; + } + })} + />)} +
;