mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-11 23:54:52 +09:00
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:
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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user