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 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-09-30 15:28:23 +00:00
1 parent 69e9f172a3
commit 4bc1773c82
23 files changed
+989 -670

No files matched your search

@@ -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,
}
-30
View File
@@ -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
}
-17
View File
@@ -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",
},
}
+165 -162
View File
@@ -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<string, Featu
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) {
checkFieldValue(field, value, `Setting ${key} of ${name}`);
if (!isDefaultValue(field, value)) {
result[key] = value;
}
}
return result;
}
function checkFieldValue(field: FeatureField, value: FeatureValue, label: string) {
switch (field.type) {
case "string_list":
if (!Array.isArray(value) || value.some(item => 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;
}
+4 -3
View File
@@ -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<string, boolean | number | string> = {},
public values: Record<string, FeatureValue> = {},
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<string, boolean | number | string>,
public values: Record<string, FeatureValue>,
) {}
static jsonSchema = Type.Object({
-18
View File
@@ -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,
})
@@ -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<string, LocalisedString | fun(player: LuaPlayer, is_command: boolean): LocalisedString>
commands = { --- @setting commands will trigger only when command prefix is given
@@ -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
}
})
@@ -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 },
},
}
})
+20 -4
View File
@@ -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,
})
@@ -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
+5 -4
View File
@@ -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,
}
}
},
})
@@ -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,
},
}
})
@@ -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,
+164 -142
View File
@@ -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<string, true>
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,
}
})
+33 -16
View File
@@ -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<string, true>
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,
}
})
+35 -11
View File
@@ -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<string, true>
local always_protected_types = {} --- @type table<string, true>
local always_trigger_repeat_names = {} --- @type table<string, true>
local always_trigger_repeat_types = {} --- @type table<string, true>
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<uint, table<string, LuaEntity>> Keyed by surface index then entity key
local protected_areas = {} --- @type table<uint, table<string, BoundingBox>> 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
+21 -24
View File
@@ -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,
}
}
},
})
+138 -56
View File
@@ -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<string, ExpScenario.FeatureConfig>
--- Copies of the defaults by feature name, used to undo overrides
local defaults = {} --- @type table<string, table<string, any>>
--- The values of each feature by feature name then path
local fields = {} --- @type table<string, table<string, ExpScenario.FeatureField>>
--- Overrides received from the controller by feature name
local overrides = {} --- @type table<string, table<string, boolean | number | string>>
--- Called after a feature is applied by feature name
local on_apply = {} --- @type table<string, fun(config: table)[]>
--- Overrides received from the controller by feature name then path
local overrides = {} --- @type table<string, table<string, boolean | number | string | string[]>>
--- 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<string, ExpScenario.FeatureField>
--- @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<string, boolean | number | string>
--- @field values table<string, boolean | number | string | string[]>
--- 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
+46 -5
View File
@@ -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();
});
+3
View File
@@ -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/<name>.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)
+45 -23
View File
@@ -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()
+293 -116
View File
@@ -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<string, FeatureValue> };
type PendingChanges = Record<string, { enabled?: boolean, values: Record<string, FeatureValue> }>;
// 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<string, FeatureField[]>();
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 <Select
showSearch
allowClear
placeholder="No role"
optionFilterProp="label"
style={{ minWidth: 200 }}
value={props.value ?? undefined}
onChange={value => props.onChange(value ?? null)}
options={[...roles.values()].map(role => ({ label: role.name, value: role.id }))}
disabled={props.disabled || !synced}
loading={!synced}
/>;
}
function FieldInput(props: { field: FeatureField, value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) {
const { field, value, disabled, onChange } = props;
switch (field.type) {
case "number":
return <InputNumber
value={value as number}
min={field.min}
addonAfter={field.unit}
disabled={disabled}
onChange={next => onChange(next ?? field.default)}
/>;
case "string":
return <Input value={value as string} disabled={disabled} onChange={e => onChange(e.target.value)} style={{ maxWidth: 400 }} />;
case "string_list":
return <Select
mode="tags"
value={value as string[]}
disabled={disabled}
onChange={next => onChange(next)}
open={false}
style={{ width: "100%", maxWidth: 600 }}
/>;
case "role":
return <RoleSelect value={value} disabled={disabled} onChange={onChange} />;
default:
return null;
}
}
/** A single setting, laid out like a permission on the role page. */
function FieldItem(props: {
field: FeatureField, value: FeatureValue, stored: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void,
}) {
const { field, value, stored, disabled, onChange } = props;
const modified = !sameValue(value, stored);
return <div style={{
marginBottom: 8,
padding: "4px 6px",
background: modified ? modifiedColor : undefined,
borderRadius: 4,
}}>
{field.type === "boolean"
? <Checkbox checked={value as boolean} disabled={disabled} onChange={e => onChange(e.target.checked)}>
{field.title}
</Checkbox>
: <Space wrap>
<span>{field.title}</span>
<FieldInput field={field} value={value} disabled={disabled} onChange={onChange} />
</Space>}
<div style={{ marginLeft: field.type === "boolean" ? 24 : 0, color: "#888", fontSize: 12 }}>
{field.description}
</div>
</div>;
}
function FeatureSection(props: {
feature: Feature,
fields: FeatureField[],
state: FeatureState,
stored: FeatureState,
canEdit: boolean,
onEnabled: (enabled: boolean) => void,
onValue: (name: string, value: FeatureValue) => void,
onReset: () => void,
}) {
const { feature, fields, state, stored, canEdit } = props;
const atDefaults = feature.fields.every(field => isDefaultValue(field, state.values[field.name]));
return <div id={`feature-${feature.name}`} style={{ border: `1px solid ${borderColor}`, padding: 12, borderRadius: 6, scrollMarginTop: 80 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{
padding: "4px 6px",
borderRadius: 4,
background: state.enabled !== stored.enabled ? modifiedColor : undefined,
}}>
<Switch checked={state.enabled} disabled={!canEdit} onChange={props.onEnabled} />
</div>
<div style={{ flexGrow: 1 }}>
<strong>{feature.title}</strong>
<div style={{ color: "#888", fontSize: 12 }}>{feature.description}</div>
</div>
{canEdit && feature.fields.length > 0 && <Button size="small" disabled={atDefaults} onClick={props.onReset}>
Reset to defaults
</Button>}
</div>
{fields.length > 0 && <>
<Divider style={{ margin: "12px 0" }} />
<div style={{ opacity: state.enabled ? 1 : 0.6 }}>
{groupFields(fields).map(([section, sectionFields]) => <div key={section}>
{section && <div style={{ fontWeight: 600, margin: "8px 6px 4px" }}>{formatSectionTitle(section)}</div>}
{sectionFields.map(field => <FieldItem
key={field.name}
field={field}
value={state.values[field.name]}
stored={stored.values[field.name]}
disabled={!canEdit}
onChange={value => props.onValue(field.name, value)}
/>)}
</div>)}
</div>
</>}
</div>;
}
function FeatureNav(props: { feature: Feature, enabled: boolean }) {
return <div
role="button"
onClick={props.onClick}
style={{
display: "flex",
alignItems: "center",
gap: token.marginXS,
padding: `${token.paddingXS}px ${token.paddingSM}px`,
marginBottom: token.marginXS,
cursor: "pointer",
borderRadius: token.borderRadius,
border: `1px solid ${props.enabled ? token.colorSuccessBorder : token.colorBorder}`,
outline: props.selected ? `2px solid ${token.colorPrimary}` : undefined,
background: props.enabled ? token.colorSuccessBg : undefined,
}}
onClick={() => document.getElementById(`feature-${props.feature.name}`)?.scrollIntoView({ behavior: "smooth" })}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "4px 8px", cursor: "pointer", borderRadius: 4 }}
>
<span style={{
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
background: props.enabled ? token.colorSuccess : token.colorTextQuaternary,
background: props.enabled ? "#52c41a" : "#595959",
}} />
{props.feature.title}
</div>;
}
/** 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 <Switch disabled={disabled} />;
case "number":
return <InputNumber disabled={disabled} min={field.min} addonAfter={field.unit} />;
case "string":
return <Input disabled={disabled} />;
}
}
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<string, FeatureValue>;
setSaving(true);
control.send(new FeatureUpdateRequest(feature.name, enabled, values))
.then(() => setDirty(false))
.catch(notifyErrorHandler(`Error saving ${feature.title}`))
.finally(() => setSaving(false));
}
return <Card
title={<>
{feature.title}
<Typography.Paragraph type="secondary" style={{ fontWeight: "normal", margin: 0 }}>
{feature.description}
</Typography.Paragraph>
</>}
>
<Form
form={form}
layout="vertical"
initialValues={initialValues}
onValuesChange={() => setDirty(true)}
onFinish={save}
>
<Form.Item name="enabled" label="Enabled" valuePropName="checked">
<Switch disabled={!canEdit} />
</Form.Item>
{feature.fields.map(field => <Form.Item
key={field.name}
name={field.name}
label={field.title}
extra={field.description}
valuePropName={field.type === "boolean" ? "checked" : "value"}
rules={field.type === "boolean" ? [] : [{ required: true, message: `${field.title} is required` }]}
>
{fieldInput(field, !canEdit)}
</Form.Item>)}
{canEdit && <Space>
<Button type="primary" htmlType="submit" loading={saving} disabled={!dirty}>Save</Button>
<Button onClick={() => {
form.setFieldsValue(defaultValues(feature));
setDirty(true);
}}>Reset to defaults</Button>
</Space>}
</Form>
</Card>;
}
/** 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<PendingChanges>({});
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 <PageLayout nav={[{ name: "Scenario Features" }]}>
<PageHeader title="Scenario Features" />
<Row gutter={[16, 16]}>
<Col xs={24} md={8} lg={6}>
<Card title="Features">
{features.map(f => <FeatureListItem
key={f.name}
feature={f}
enabled={records.get(f.name)?.enabled ?? true}
selected={f.name === selected}
onClick={() => setSelected(f.name)}
{Object.keys(pending).length > 0 && <div style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: modifiedColor,
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 12,
zIndex: 1000,
}}>
<span style={{ color: "#FFF" }}>You have unsaved changes</span>
<Space>
<Button onClick={() => setPending({})}>Revert</Button>
<Button type="primary" onClick={applyChanges}>Apply</Button>
</Space>
</div>}
<Input.Search placeholder="Search features and settings" allowClear
onChange={e => setSearch(e.target.value)} style={{ marginBottom: 16 }} />
<Row gutter={16} wrap={false}>
<Col flex="200px">
{/* Clear of the fixed page header */}
<div style={{ position: "sticky", top: 80 }}>
{shown.map(({ feature }) => <FeatureNav
key={feature.name}
feature={feature}
enabled={current(feature.name).enabled}
/>)}
</Card>
</div>
</Col>
<Col xs={24} md={16} lg={18}>
{feature
// Remount on save from anywhere so the form shows the stored values
? <FeatureForm key={`${feature.name}:${record?.updatedAtMs ?? 0}`} feature={feature} record={record} />
: <Card><Empty /></Card>}
<Col flex="auto" style={{ minWidth: 0 }}>
<Space direction="vertical" style={{ width: "100%" }}>
{shown.map(({ feature, fields }) => <FeatureSection
key={feature.name}
feature={feature}
fields={fields}
state={current(feature.name)}
stored={stored[feature.name]}
canEdit={canEdit}
onEnabled={enabled => 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;
}
})}
/>)}
</Space>
</Col>
</Row>
</PageLayout>;