diff --git a/exp_legacy/module/config/afk_kick.lua b/exp_legacy/module/config/afk_kick.lua deleted file mode 100644 index 3e52552a..00000000 --- a/exp_legacy/module/config/afk_kick.lua +++ /dev/null @@ -1,14 +0,0 @@ -local Roles = require("modules/exp_roles") - -return { - admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time - trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time - afk_time = 3600 * 10, --- @setting afk_time The time in ticks that must pass for a player to be considered afk - kick_time = 3600 * 30, --- @setting kick_time The time in ticks that must pass without any active players for all players to be kicked - trust_time = 3600 * 60 * 10, --- @setting trust_time The time in ticks that a player must be online for to count as trusted - update_time = 3600 * 30, --- @setting update_time How often in ticks the script checks for active players - custom_active_check = function(player) - local veteran = Roles.get_role_by_name("Veteran") - return veteran ~= nil and not Roles.get_player_highest_role(player):is_lower_than(veteran) - end, -} diff --git a/exp_legacy/module/config/death_logger.lua b/exp_legacy/module/config/death_logger.lua deleted file mode 100644 index ca90677a..00000000 --- a/exp_legacy/module/config/death_logger.lua +++ /dev/null @@ -1,15 +0,0 @@ ---- This config controls what happens when a player dies mostly about map markers and item collection; --- allow_teleport_to_body_command and allow_collect_bodies_command can be over ridden if command_auth_runtime_disable is present; --- if not present then the commands will not be loaded into the game --- @config Death-Logger - -return { - collect_corpses = true, --- @setting collect_corpses enables items being returned to the spawn point in chests upon corpse expiring - show_map_markers = true, --- @setting show_map_markers shows markers on the map where bodies are - clean_map_markers = false, - include_time_of_death = true, --- @setting include_time_of_death weather to include the time of death on the map marker - map_icon = nil, --- @setting map_icon the icon that the map marker shows; nil means no icon; format as a SingleID - show_light_at_corpse = true, --- @setting show_light_at_corpse if a light should be rendered at the corpse - show_line_to_corpse = true, --- @setting show_line_to_corpse if a line should be rendered from you to your corpse - period_check_map_tags = 60 * 60 * 5, -} diff --git a/exp_legacy/module/config/discord_alerts.lua b/exp_legacy/module/config/discord_alerts.lua deleted file mode 100644 index bc51d78c..00000000 --- a/exp_legacy/module/config/discord_alerts.lua +++ /dev/null @@ -1,23 +0,0 @@ ---- Config file used to enable and disable different push messages for discord --- @config Discord-Alerts - -return { - show_playtime = true, - entity_protection = true, - player_bans = true, - player_mutes = true, - player_kicks = true, - player_promotes = false, - player_jail = true, - ["config"] = true, - ["purge"] = true, - ["c"] = true, - ["command"] = true, - ["silent-command"] = true, - ["measured-command"] = true, - ["banlist"] = true, - ["permissions"] = true, - ["editor"] = true, - ["cheat"] = true, - ["open"] = false, -} diff --git a/exp_legacy/module/config/lawnmower.lua b/exp_legacy/module/config/lawnmower.lua deleted file mode 100644 index f31559c8..00000000 --- a/exp_legacy/module/config/lawnmower.lua +++ /dev/null @@ -1,6 +0,0 @@ ---- Settings for lawnmower --- @config lawnmower - -return { - destroy_decoratives = false, -} diff --git a/exp_legacy/module/config/miner.lua b/exp_legacy/module/config/miner.lua deleted file mode 100644 index f6fcf84c..00000000 --- a/exp_legacy/module/config/miner.lua +++ /dev/null @@ -1,8 +0,0 @@ ---- Settings for miner --- @config miner - -return { - fluid = true, --- @setting fluid When true, checks for for fluid pipes when removing miners - chest = true, --- @setting chest When true, checks for for chest when removing miners - beacon = true, --- @setting beacon When true, checks for for beacon when removing miners -} diff --git a/exp_legacy/module/config/nukeprotect.lua b/exp_legacy/module/config/nukeprotect.lua deleted file mode 100644 index 197ddb00..00000000 --- a/exp_legacy/module/config/nukeprotect.lua +++ /dev/null @@ -1,30 +0,0 @@ -return { - inventories = { - { - inventory = defines.inventory.character_ammo, - event = defines.events.on_player_ammo_inventory_changed, - items = { - ["atomic-bomb"] = true, - }, - }, - { - inventory = defines.inventory.character_armor, - event = defines.events.on_player_armor_inventory_changed, - items = {}, - }, - { - inventory = defines.inventory.character_guns, - event = defines.events.on_player_gun_inventory_changed, - items = {}, - }, - { - inventory = defines.inventory.character_main, - event = defines.events.on_player_main_inventory_changed, - items = { - ["atomic-bomb"] = true, - }, - }, - }, - ignore_permission = "exp_scenario.bypass.nuke_protection", -- @setting ignore_permission The permission that nukeprotect will ignore - ignore_admins = true, -- @setting ignore_admins Ignore admins, true by default. Allows usage outside of the roles module -} diff --git a/exp_legacy/module/config/popup_messages.lua b/exp_legacy/module/config/popup_messages.lua deleted file mode 100644 index 3a098d0e..00000000 --- a/exp_legacy/module/config/popup_messages.lua +++ /dev/null @@ -1,10 +0,0 @@ ---- A combination of config settings for different popup values like chat and damage --- @config Popup-Messages - -return { - show_player_messages = true, --- @setting show_player_messages weather a message in chat will make a popup above them - show_player_mentions = true, --- @setting show_player_mentions weather a mentioned player will have a popup when mentioned in chat - show_player_damage = true, --- @setting show_player_damage weather to show damage done by players - show_player_health = true, --- @setting show_player_health weather to show player health when attacked - damage_location_variance = 0.8, --- @setting damage_location_variance how close to the eade of an entity the popups will appear -} diff --git a/exp_legacy/module/config/protection.lua b/exp_legacy/module/config/protection.lua deleted file mode 100644 index d645d04b..00000000 --- a/exp_legacy/module/config/protection.lua +++ /dev/null @@ -1,17 +0,0 @@ -return { - ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter - ignore_permission = "exp_scenario.bypass.entity_protection", --- @setting ignore_permission Players with this permission will be ignored by the protection filter, leave nil to protect against every player - repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_lifetime in order to trigger repeated removal protection - repeat_lifetime = 3600 * 20, --- @setting repeat_lifetime The length of time, in ticks, that protected removals will be remembered for - refresh_rate = 3600 * 5, --- @setting refresh_rate How often the age of protected removals are checked against repeat_lifetime - always_protected_names = { --- @setting always_protected_names Names of entities which are always protected - }, - always_protected_types = { --- @setting always_protected_types Types of entities which are always protected - "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", - }, - always_trigger_repeat_names = { --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection - }, - always_trigger_repeat_types = { --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection - "reactor", "fusion-reactor", "rocket-silo", - }, -} diff --git a/exp_legacy/module/config/station_auto_name.lua b/exp_legacy/module/config/station_auto_name.lua deleted file mode 100644 index 60f93124..00000000 --- a/exp_legacy/module/config/station_auto_name.lua +++ /dev/null @@ -1,11 +0,0 @@ -return { - --[[ - __icon__ - __item_name__ - __backer_name__ - __direction__ - __x__ - __y__ - ]] - station_name = "[L] __icon__", -} diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 2914f98c..7c7547c4 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -1,23 +1,99 @@ import * as lib from "@clusterio/lib"; +import * as path from "node:path"; import type { Controller, ControllerPluginContext } from "@clusterio/controller"; import { RoleMetaRecord } from "@expcluster/roles"; import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups"; import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js"; import { ControllerPlugin as GroupsPlugin } from "@expcluster/permission-groups/dist/node/controller.js"; import * as messages from "./messages.js"; +import { features, pruneFeatureValues, validateFeatureValues } from "./features.js"; import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed.js"; export class ControllerPlugin { controller: Controller; logger: lib.Logger; + name: string; + features!: lib.SubscribableDatastore; constructor(context: ControllerPluginContext) { this.controller = context.controller; this.logger = context.logger; + this.name = context.plugin.name; } async init() { + const databaseDirectory = this.controller.config.get("controller.database_directory"); + this.features = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_scenario", "features.json"), + messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord), + ).bootstrap() + ); + + this.reconcileFeatures(); + + this.controller.subscriptions.handle(messages.FeatureUpdatedEvent, this.handleFeatureSubscription.bind(this)); + this.features.on("update", this.featuresUpdated.bind(this)); + this.controller.handle(messages.SeedRequest, this.handleSeedRequest.bind(this)); + this.controller.handle(messages.FeatureListRequest, this.handleFeatureListRequest.bind(this)); + this.controller.handle(messages.FeatureUpdateRequest, this.handleFeatureUpdateRequest.bind(this)); + + this.controller.hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); + } + + async onShutdown() { + await this.features.save(); + } + + /** Add new features, delete removed ones, and drop stored values which no longer fit a field */ + reconcileFeatures() { + const declared = new Map(features.map(feature => [feature.name, feature])); + for (const record of this.features.values()) { + const feature = declared.get(record.id); + if (!feature) { + this.logger.warn(`Dropping stored config of removed feature ${record.id}`); + this.features.delete(record); + continue; + } + + const { kept, dropped } = pruneFeatureValues(feature, record.values); + if (dropped.length) { + this.logger.warn(`Dropping stored values of ${record.id} which no longer match a setting: ${dropped.join(", ")}`); + this.features.set(new messages.FeatureRecord(record.id, record.enabled, kept)); + } + } + + const missing = features.filter(feature => !this.features.has(feature.name)); + if (missing.length) { + this.features.setMany(missing.map(feature => new messages.FeatureRecord(feature.name, true))); + } + } + + featuresUpdated(updates: messages.FeatureRecord[]) { + this.controller.subscriptions.broadcast(new messages.FeatureUpdatedEvent(updates)); + } + + async handleFeatureSubscription(request: lib.SubscriptionRequest) { + const updates = [...this.features.values()].filter(feature => feature.updatedAtMs > request.lastRequestTimeMs); + return updates.length ? new messages.FeatureUpdatedEvent(updates) : null; + } + + async handleFeatureListRequest() { + return [...this.features.values()]; + } + + async handleFeatureUpdateRequest(request: messages.FeatureUpdateRequest) { + let values; + try { + values = validateFeatureValues(request.id, request.values); + } catch (err: any) { + throw new lib.RequestError(err.message); + } + + const feature = new messages.FeatureRecord(request.id, request.enabled, values); + this.features.set(feature); + return feature; } /** diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts new file mode 100644 index 00000000..758ae798 --- /dev/null +++ b/exp_scenario/features.ts @@ -0,0 +1,288 @@ +/** + * The features which can be configured from the web interface. Defaults must + * match the lua side, test/features.test.js checks this. + */ + +export type FeatureValue = boolean | number | string | string[] | null; + +/** A setting, shaped like a clusterio config field so the config inputs can render it */ +export type FeatureField = { + name: string, + title: string, + description: string, +} & ( + | ScalarField<{ type: "boolean", default: boolean }> + | ScalarField<{ type: "number", default: number | null, min?: number, unit?: string }> + | ScalarField<{ type: "string", default: string | null, enum?: string[] }> + /** Feature.list or Feature.set on the lua side, both are sent as a list */ + | { type: "string_list", default: string[] } +); + +type ScalarField = T & { + /** Allows null, the lua side uses Feature.optional */ + optional?: boolean, + /** Name of an input component registered with the web interface, such as "role" */ + inputComponent?: string, +}; + +export type Feature = { + name: string, + title: string, + description: string, + 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 }; +} + +/** Id of a clusterio role, null when no role is picked */ +function role(name: string, title: string, description: string): FeatureField { + return { name, title, description, type: "number", default: null, optional: true, inputComponent: "role" }; +} + +export const features: Feature[] = [ + { + name: "afk_kick", + title: "AFK Kick", + description: "Kick everyone when nobody online is active", + fields: [ + 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"), + ], + }, + { + name: "autofill", + title: "Autofill", + description: "Fill ammo and fuel into entities when they are built, configured by each player from the toolbar", + fields: [], + }, + { + name: "chat_auto_reply", + title: "Chat Auto Reply", + description: "Reply to key words in chat and run chat commands", + fields: [ + str("command_prefix", "Command prefix", "Prefix for chat commands, and for key words to reply to everyone", "!"), + ], + }, + { + name: "death_markers", + title: "Death Markers", + description: "What happens when players die", + fields: [ + 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", + ]), + ], + }, + { + name: "lawnmower", + title: "Lawnmower", + description: "The /lawnmower command which clears corpses and nuclear ground", + fields: [ + bool("destroy_decoratives", "Clear decoratives when building", "Remove decoratives under entities when they are built", false), + ], + }, + { + name: "mine_depletion", + title: "Mine Depletion", + description: "Deconstruct mining drills when their resources run out", + fields: [ + 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: [ + 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: [ + 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", + ]), + ], + }, + { + name: "station_auto_name", + title: "Station Auto Name", + description: "Name train stops when they are built", + fields: [ + str("station_name", "Station name", "Placeholders: __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__", "[L] __icon__"), + ], + }, +]; + +/** Check values against the fields of a feature, returning those which differ from their default */ +export function validateFeatureValues(name: string, values: Record) { + const feature = features.find(f => f.name === name); + if (!feature) { + throw new Error(`Unknown feature ${name}`); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(values)) { + const field = feature.fields.find(f => f.name === key); + if (!field) { + throw new Error(`Feature ${name} has no setting ${key}`); + } + const error = fieldValueError(field, value); + if (error) { + throw new Error(`Setting ${key} of ${name} ${error}`); + } + if (!isDefaultValue(field, value)) { + result[key] = value; + } + } + return result; +} + +/** Why a value does not fit a field, undefined when it does */ +function fieldValueError(field: FeatureField, value: FeatureValue) { + if (value === null) { + return field.type === "string_list" || !field.optional ? "can not be null" : undefined; + } + switch (field.type) { + case "string_list": + if (!Array.isArray(value) || value.some(item => typeof item !== "string")) { + return "must be a list of strings"; + } + return undefined; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) { + return "must be a finite number"; + } + if (field.min !== undefined && value < field.min) { + return `must be at least ${field.min}`; + } + return undefined; + case "string": + if (typeof value !== "string") { + return "must be a string"; + } + if (field.enum && !field.enum.includes(value)) { + return `must be one of ${field.enum.join(", ")}`; + } + return undefined; + default: + return typeof value === field.type ? undefined : `must be a ${field.type}`; + } +} + +/** Check if two values are equal, lists are compared by content. */ +export 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; +} + +export function isDefaultValue(field: FeatureField, value: FeatureValue) { + return sameValue(value, field.default); +} + +/** Split stored values into those which still fit a field and the names of the rest */ +export function pruneFeatureValues(feature: Feature, values: Record) { + const kept: Record = {}; + const dropped: string[] = []; + for (const [key, value] of Object.entries(values)) { + const field = feature.fields.find(f => f.name === key); + if (field && !fieldValueError(field, value)) { + kept[key] = value; + } else { + dropped.push(key); + } + } + return { kept, dropped }; +} diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 203c5721..5e4a721a 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -20,6 +20,9 @@ export const plugin: lib.PluginDeclaration = { messages: [ messages.SeedRequest, + messages.FeatureUpdatedEvent, + messages.FeatureListRequest, + messages.FeatureUpdateRequest, ], permissions: [ @@ -42,4 +45,7 @@ export const plugin: lib.PluginDeclaration = { ], webEntrypoint: "./web", + routes: [ + "/scenario_features", + ], }; diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index 95a41ff8..7a0f9f5a 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -1,3 +1,85 @@ -// Empty because an instance entrypoint must be defined for a module to be injected -// This requirement may change in the future to allow for standalone modules -export default async function () {} +import type { Instance, InstancePluginContext } from "@clusterio/host"; +import * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; +import { features, FeatureValue } from "./features.js"; + +/** Sends feature config to the lua side, see module/features.lua. */ +export class InstancePlugin { + instance: Instance; + logger: lib.Logger; + name: string; + /** Rcon is up and the lua side can take updates */ + started = false; + + constructor(context: InstancePluginContext) { + this.instance = context.instance; + this.logger = context.logger; + this.name = context.plugin.name; + } + + async init() { + this.instance.handle(messages.FeatureUpdatedEvent, this.handleFeatureUpdatedEvent.bind(this)); + this.instance.hooks.start.attach(this.name, this.onStart.bind(this)); + this.instance.hooks.exit.attach(this.name, this.onExit.bind(this)); + this.instance.hooks.controllerConnectionEvent.attach(this.name, this.onControllerConnectionEvent.bind(this)); + } + + async onStart() { + this.started = true; + await this.syncFeatures(); + } + + async onExit() { + this.started = false; + } + + /** A new session means the controller restarted and forgot the subscription. */ + async onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { + if (event === "connect" && this.started) { + await this.syncFeatures(); + } + } + + /** Subscribe, then send every feature */ + async syncFeatures() { + await this.instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_scenario:${messages.FeatureUpdatedEvent.name}`, "subscribe", Date.now(), + )); + const features = await this.instance.sendTo("controller", new messages.FeatureListRequest()); + await this.luaSendFeatures(features); + } + + async handleFeatureUpdatedEvent(event: messages.FeatureUpdatedEvent) { + if (this.started) { + await this.luaSendFeatures(event.updates.filter(feature => !feature.isDeleted)); + } + } + + /** Every value of each feature is sent, so the lua side sets them all */ + async luaSendFeatures(records: messages.FeatureRecord[]) { + if (!records.length) { + return; + } + + const updates = records.map(record => { + const values: Record = { enabled: record.enabled }; + for (const field of features.find(feature => feature.name === record.id)?.fields ?? []) { + values[field.name] = field.name in record.values ? record.values[field.name] : field.default; + } + return { name: record.id, values }; + }); + const json = JSON.stringify(updates); + // Pick a long bracket the json does not contain + let level = "="; + while (json.includes(`]${level}]`)) { + level += "="; + } + await this.instance.sendRcon( + `/sc exp_scenario.features.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true, + ); + } +} + +export default async function (context: InstancePluginContext) { + await new InstancePlugin(context).init(); +} diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index 9c778456..3438c061 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -1,3 +1,7 @@ +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 { declare ["constructor"]: typeof SeedRequest; @@ -9,3 +13,116 @@ export class SeedRequest { constructor() {} } + +const FeatureValueSchema = Type.Union([Type.Boolean(), Type.Number(), Type.String(), Type.Array(Type.String()), Type.Null()]); + +/** The config of one feature, see features.ts. Only values which differ from the default are stored. */ +export class FeatureRecord { + constructor( + /** Name of the feature in features.ts */ + public id: string, + public enabled: boolean, + public values: Record = {}, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + enabled: Type.Boolean(), + values: Type.Record(Type.String(), FeatureValueSchema), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + enabled: this.enabled, + values: this.values, + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.enabled, + json.values, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +export class FeatureUpdatedEvent { + declare ["constructor"]: typeof FeatureUpdatedEvent; + static plugin = "exp_scenario" as const; + static type = "event" as const; + static src = "controller" as const; + static dst = ["control", "instance"] as const; + static permission = "exp_scenario.config.view" as const; + + constructor( + public updates: FeatureRecord[], + ) {} + + static jsonSchema = Type.Object({ + updates: Type.Array(FeatureRecord.jsonSchema), + }); + + toJSON() { + return { updates: this.updates.map(feature => feature.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.updates.map(feature => FeatureRecord.fromJSON(feature))); + } +} + +/** List the config of every feature. */ +export class FeatureListRequest { + declare ["constructor"]: typeof FeatureListRequest; + static plugin = "exp_scenario" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_scenario.config.view" as const; + static Response = lib.jsonArray(FeatureRecord); +} + +/** Replace the config of a feature, values left out go back to their default. */ +export class FeatureUpdateRequest { + declare ["constructor"]: typeof FeatureUpdateRequest; + static plugin = "exp_scenario" as const; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "exp_scenario.config.edit" as const; + static Response = FeatureRecord; + + constructor( + public id: string, + public enabled: boolean, + public values: Record, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + enabled: Type.Boolean(), + values: Type.Record(Type.String(), FeatureValueSchema), + }); + + static fromJSON(json: Static) { + return new this(json.id, json.enabled, json.values); + } +} diff --git a/exp_scenario/module/commands/_authorities.lua b/exp_scenario/module/commands/_authorities.lua index 37093ad8..7379e4e4 100644 --- a/exp_scenario/module/commands/_authorities.lua +++ b/exp_scenario/module/commands/_authorities.lua @@ -5,6 +5,7 @@ Adds a permission authority which checks the clusterio permission for a command local Commands = require("modules/exp_commands") local add, allow, deny = Commands.add_permission_authority, Commands.status.success, Commands.status.unauthorised +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local player_has_permission = Roles.player_has_permission @@ -22,6 +23,17 @@ authorities.exp_permission = end end) +--- Commands flagged with a feature are denied while it is disabled, see features.lua +authorities.feature_enabled = + add(function(player, command) + local feature = command.flags.feature + if feature and not feature:is_enabled() then + return deny{ "exp-commands-authorities_feature.deny" } + else + return allow() + end + end) + Roles.define_permission_trigger("core.admin", function(player, state) if state then Commands.unlock_system_commands(player.name) diff --git a/exp_scenario/module/commands/lawnmower.lua b/exp_scenario/module/commands/lawnmower.lua index 0dbba6f6..26cc33fa 100644 --- a/exp_scenario/module/commands/lawnmower.lua +++ b/exp_scenario/module/commands/lawnmower.lua @@ -4,9 +4,13 @@ Adds a command that clean up biter corpse and nuclear hole local AABB = require("modules/exp_util/aabb") local Commands = require("modules/exp_commands") +local Feature = require("modules/exp_scenario/features") local Selection = require("modules/exp_util/selection") local SelectArea = Selection.connect("ExpCommand_Lawnmower") -local config = require("modules.exp_legacy.config.lawnmower") + +local feature, config = Feature.register("lawnmower", { + destroy_decoratives = false, -- remove decoratives under entities when they are built +}) --- @class ExpCommand_Lawnmower.commands local commands = {} @@ -15,6 +19,7 @@ local commands = {} --- @class ExpCommands_Lawnmower.commands.lawnmower: ExpCommand --- @overload fun(player: LuaPlayer) commands.lawnmower = Commands.new("lawnmower", { "exp-commands_lawnmower.description" }) + :add_flags{ feature = feature } :register(function(player) if SelectArea:stop(player) then return Commands.status.success{ "exp_util.selection_exit", { "exp-commands_lawnmower.selection-name" } } @@ -51,6 +56,7 @@ end) --- @param event EventData.on_built_entity | EventData.on_robot_built_entity | EventData.script_raised_built | EventData.script_raised_revive local function destroy_decoratives(event) + if not config.destroy_decoratives then return end local entity = event.entity if entity.type ~= "entity-ghost" and entity.type ~= "tile-ghost" and entity.prototype.selectable_in_game then entity.surface.destroy_decoratives{ area = entity.selection_box } @@ -58,16 +64,13 @@ local function destroy_decoratives(event) end local e = defines.events -local events = {} -if config.destroy_decoratives then - events[e.on_built_entity] = destroy_decoratives - events[e.on_robot_built_entity] = destroy_decoratives - events[e.script_raised_built] = destroy_decoratives - events[e.script_raised_revive] = destroy_decoratives -end - -return { - events = events, +return feature:guard{ + events = { + [e.on_built_entity] = destroy_decoratives, + [e.on_robot_built_entity] = destroy_decoratives, + [e.script_raised_built] = destroy_decoratives, + [e.script_raised_revive] = destroy_decoratives, + }, commands = commands, } diff --git a/exp_scenario/module/commands/protected_entities.lua b/exp_scenario/module/commands/protected_entities.lua index 34b66783..b35861e2 100644 --- a/exp_scenario/module/commands/protected_entities.lua +++ b/exp_scenario/module/commands/protected_entities.lua @@ -153,6 +153,17 @@ SelectArea:on_alt_selection(function(event) end end) +--- The keys of a set as a list, find_entities_filtered takes a list of names +--- @param set table +--- @return string[] +local function set_keys(set) + local keys = {} + for key in pairs(set) do + keys[#keys + 1] = key + end + return keys +end + --- When selection starts show all protected entities and protected areas local function on_player_selection_start(event) local player = game.players[event.player_index] @@ -166,15 +177,17 @@ local function on_player_selection_start(event) end -- Show always protected entities by name - if #EntityProtection.protected_entity_names > 0 then - for _, entity in pairs(surface.find_entities_filtered{ name = EntityProtection.protected_entity_names, force = player.force }) do + local protected_entity_names = set_keys(EntityProtection.config.always_protected_names) + if #protected_entity_names > 0 then + for _, entity in pairs(surface.find_entities_filtered{ name = protected_entity_names, force = player.force }) do show_protected_entity(player, entity) end end -- Show always protected entities by type - if #EntityProtection.protected_entity_types > 0 then - for _, entity in pairs(surface.find_entities_filtered{ type = EntityProtection.protected_entity_types, force = player.force }) do + local protected_entity_types = set_keys(EntityProtection.config.always_protected_types) + if #protected_entity_types > 0 then + for _, entity in pairs(surface.find_entities_filtered{ type = protected_entity_types, force = player.force }) do show_protected_entity(player, entity) end end diff --git a/exp_legacy/module/config/chat_reply.lua b/exp_scenario/module/config/chat_auto_reply.lua similarity index 90% rename from exp_legacy/module/config/chat_reply.lua rename to exp_scenario/module/config/chat_auto_reply.lua index 7b624fcf..a9e627b9 100644 --- a/exp_legacy/module/config/chat_reply.lua +++ b/exp_scenario/module/config/chat_auto_reply.lua @@ -1,7 +1,8 @@ --- This file defines the different triggers for the chat bot --- @config Chat-Reply +-- @config Chat-Auto-Reply local ExpUtil = require("modules/exp_util") +local Feature = require("modules/exp_scenario/features") local Async = require("modules/exp_util/async") local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail @@ -24,9 +25,9 @@ local afk_time_units = { seconds = true, } -return { +return Feature.register("chat_auto_reply", { --- @type table - messages = { --- @setting messages will trigger when ever the word is said + messages = { -- will trigger when ever the word is said ["discord"] = { "info.discord", config_server_detail["discord"] }, ["expgaming"] = { "info.website", config_server_detail["website"] }, ["website"] = { "info.website", config_server_detail["website"] }, @@ -64,12 +65,10 @@ return { return { "exp_chat-auto-reply.reply-afk", max.name, ExpUtil.format_time_locale(max.afk_time, "long", afk_time_units) } end, }, - 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) + allow_command_prefix_for_messages = true, -- when true any message trigger will print to all player when prefixed + command_prefix = "!", -- prefix used for commands below and to print to all players (if enabled above) --- @type table - commands = { --- @setting commands will trigger only when command prefix is given + commands = { -- will trigger only when command prefix is given ["dev"] = { "exp_chat-auto-reply.reply-dev" }, ["magic"] = { "exp_chat-auto-reply.reply-magic" }, ["aids"] = { "exp_chat-auto-reply.reply-aids" }, @@ -134,4 +133,4 @@ return { return { locale_reply, { "exp_chat-auto-reply.reply-beer-1" } } end, }, -} +}) diff --git a/exp_legacy/module/config/spawn_area.lua b/exp_scenario/module/config/spawn_area.lua similarity index 93% rename from exp_legacy/module/config/spawn_area.lua rename to exp_scenario/module/config/spawn_area.lua index 72ee8c8e..605ac94f 100644 --- a/exp_legacy/module/config/spawn_area.lua +++ b/exp_scenario/module/config/spawn_area.lua @@ -1,41 +1,42 @@ --- 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 { - spawn_area = { --- @setting spawn_area Settings relating to the whole spawn area +local Feature = require("modules/exp_scenario/features") + +return Feature.register("spawn_area", { + 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 + deconstruction_radius = 20, -- All entities within this radius will be removed tile_radius = 20, - deconstruction_tile = "concrete", --- @setting deconstruction_tile Tile to be placed in the deconstruction radius, use nil for map gen - landfill_radius = 50, --- @setting pattern_radius All water within this radius will be land filled + deconstruction_tile = "concrete", -- Tile to be placed in the deconstruction radius, use nil for map gen + landfill_radius = 50, -- All water within this radius will be land filled }, - 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 + turrets = { -- Settings relating to adding turrets to spawn + enabled = true, -- Whether turrets will be added to spawn + ammo_type = "uranium-rounds-magazine", -- The ammo type that will be used during refills + offset = { x = 0, y = 0 }, -- The position offset to apply to turrets + locations = { -- The locations of all turrets, this list can change during runtime { -3, -3 }, { 3, -3 }, { -3, 3 }, { 3, 3 }, }, }, - afk_belts = { --- @setting afk_belts Settings relating to adding afk belts to spawn - enabled = true, --- @setting enabled Whether afk belts will be added to spawn - belt_type = "transport-belt", --- @setting belt_type The belt to be used as afk belts - protected = true, --- @setting protected Whether belts will be protected from player interaction - offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to afk belts - locations = { --- @setting locations The locations to spawn afk belts at, given as the top left position + afk_belts = { -- Settings relating to adding afk belts to spawn + enabled = true, -- Whether afk belts will be added to spawn + belt_type = "transport-belt", -- The belt to be used as afk belts + protected = true, -- Whether belts will be protected from player interaction + offset = { x = 0, y = 0 }, -- The position offset to apply to afk belts + locations = { -- The locations to spawn afk belts at, given as the top left position { -5, -5 }, { 5, -5 }, { -5, 5 }, { 5, 5 }, }, }, - water = { --- @setting water Settings relating to adding water to spawn - enabled = true, --- @setting enabled Whether water tiles will be added to spawn - water_tile = "water-mud", --- @setting water_tile The tile to be used as the water tile - offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to water tiles - locations = { --- @setting locations The location of the water tiles {x,y} + water = { -- Settings relating to adding water to spawn + enabled = true, -- Whether water tiles will be added to spawn + water_tile = "water-mud", -- The tile to be used as the water tile + offset = { x = 0, y = 0 }, -- The position offset to apply to water tiles + locations = { -- The location of the water tiles {x,y} -- Each is a 3x3 with the closest tile to 0,0 removed { 7, 8 }, { 7, 9 }, { 8, 7 }, { 8, 8 }, { 8, 9 }, { 9, 7 }, { 9, 8 }, { 9, 9 }, -- Bottom Right { 7, -9 }, { 7, -10 }, { 8, -8 }, { 8, -9 }, { 8, -10 }, { 9, -8 }, { 9, -9 }, { 9, -10 }, -- Top Right @@ -43,12 +44,12 @@ return { { -8, 8 }, { -8, 9 }, { -9, 7 }, { -9, 8 }, { -9, 9 }, { -10, 7 }, { -10, 8 }, { -10, 9 }, -- Bottom Left }, }, - entities = { --- @setting entities Settings relating to adding entities to spawn - enabled = true, --- @setting enabled Whether entities will be added to spawn - protected = true, --- @setting protected Whether entities will be protected from player interaction - operable = true, --- @setting operable Whether entities can be opened by players, must be true if chests are used - offset = { x = 0, y = -2 }, --- @setting offset The position offset to apply to entities - locations = { --- @setting locations The location and names of entities {name,x,y} + entities = { -- Settings relating to adding entities to spawn + enabled = true, -- Whether entities will be added to spawn + protected = true, -- Whether entities will be protected from player interaction + operable = true, -- Whether entities can be opened by players, must be true if chests are used + offset = { x = 0, y = -2 }, -- The position offset to apply to entities + locations = { -- The location and names of entities {name,x,y} { "stone-wall", -10, -5 }, { "stone-wall", -10, -4 }, { "stone-wall", -10, -3 }, { "stone-wall", -10, -2 }, { "stone-wall", -10, -1 }, { "stone-wall", -10, 0 }, { "stone-wall", -10, 3 }, { "stone-wall", -10, 4 }, { "stone-wall", -10, 5 }, { "stone-wall", -10, 6 }, { "stone-wall", -10, 7 }, { "stone-wall", -10, 8 }, { "small-lamp", -8, -4 }, { "small-lamp", -8, -1 }, { "iron-chest", -8, 0 }, { "iron-chest", -8, 3 }, { "small-lamp", -8, 4 }, { "small-lamp", -8, 7 }, { "stone-wall", -7, -8 }, { "small-electric-pole", -7, -2 }, { "iron-chest", -7, 0 }, { "iron-chest", -7, 3 }, { "small-electric-pole", -7, 5 }, { "stone-wall", -7, 11 }, { "stone-wall", -6, -8 }, { "small-lamp", -6, -6 }, @@ -66,10 +67,10 @@ return { }, }, pattern = { - enabled = true, --- @setting enabled Whether pattern tiles will be added to spawn - pattern_tile = "stone-path", --- @setting pattern_tile The tile to be used for the pattern - offset = { x = 0, y = -2 }, --- @setting offset The position offset to apply to pattern tiles - locations = { --- @setting locations The location of the pattern tiles {x,y} + enabled = true, -- Whether pattern tiles will be added to spawn + pattern_tile = "stone-path", -- The tile to be used for the pattern + offset = { x = 0, y = -2 }, -- The position offset to apply to pattern tiles + locations = { -- The location of the pattern tiles {x,y} { -49, -3 }, { -49, -2 }, { -49, 1 }, { -49, 2 }, { -49, 5 }, { -49, 6 }, { -48, -4 }, { -48, -3 }, { -48, -2 }, { -48, 1 }, { -48, 2 }, { -48, 5 }, { -48, 6 }, { -48, 7 }, { -47, -7 }, { -47, -6 }, { -47, -5 }, { -47, -4 }, { -47, -3 }, { -47, -2 }, { -47, 5 }, { -47, 6 }, { -47, 7 }, { -47, 8 }, { -47, 9 }, { -47, 10 }, { -46, -8 }, { -46, -7 }, { -46, -6 }, { -46, -5 }, { -46, -4 }, { -46, -3 }, { -46, -2 }, { -46, -1 }, { -46, 4 }, { -46, 5 }, { -46, 6 }, { -46, 7 }, { -46, 8 }, { -46, 9 }, { -46, 10 }, { -46, 11 }, { -45, -17 }, { -45, -16 }, { -45, -15 }, { -45, -14 }, { -45, -13 }, { -45, -12 }, { -45, -9 }, { -45, -8 }, { -45, -7 }, { -45, -2 }, { -45, -1 }, { -45, 0 }, { -45, 1 }, { -45, 2 }, { -45, 3 }, { -45, 4 }, { -45, 5 }, { -45, 10 }, { -45, 11 }, { -45, 12 }, { -45, 15 }, { -45, 16 }, { -45, 17 }, { -45, 18 }, { -45, 19 }, { -45, 20 }, { -44, -18 }, { -44, -17 }, { -44, -16 }, { -44, -15 }, { -44, -14 }, { -44, -13 }, { -44, -12 }, { -44, -9 }, { -44, -8 }, { -44, -1 }, { -44, 0 }, { -44, 1 }, { -44, 2 }, { -44, 3 }, { -44, 4 }, { -44, 11 }, { -44, 12 }, { -44, 15 }, { -44, 16 }, { -44, 17 }, { -44, 18 }, { -44, 19 }, @@ -242,8 +243,7 @@ return { resource_refill_nearby = { enabled = false, range = 128, - refill_time = 36000, - resources_name = { + resources_name = Feature.list{ "iron-ore", "copper-ore", "stone", @@ -252,4 +252,4 @@ return { }, amount = { 2500, 4000 }, }, -} +}) diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index db632016..a7702f6d 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -11,6 +11,7 @@ require("modules/exp_scenario/commands/_types") --- Commands with events add(require("modules/exp_scenario/commands/protected_entities")) add(require("modules/exp_scenario/commands/protected_tags")) +add(require("modules/exp_scenario/commands/lawnmower")) --- Commands require("modules/exp_scenario/commands/admin_chat") @@ -24,7 +25,6 @@ require("modules/exp_scenario/commands/enemy") require("modules/exp_scenario/commands/home") require("modules/exp_scenario/commands/jail") require("modules/exp_scenario/commands/kill") -require("modules/exp_scenario/commands/lawnmower") require("modules/exp_scenario/commands/locate") require("modules/exp_scenario/commands/me") require("modules/exp_scenario/commands/rainbow") @@ -43,13 +43,12 @@ require("modules/exp_scenario/commands/waterfill") add(require("modules/exp_scenario/control/afk_kick")) add(require("modules/exp_scenario/control/bonus")) add(require("modules/exp_scenario/control/chat_auto_reply")) -add(require("modules/exp_scenario/control/chat_popup")) add(require("modules/exp_scenario/control/custom_start")) -add(require("modules/exp_scenario/control/damage_popups")) add(require("modules/exp_scenario/control/death_markers")) add(require("modules/exp_scenario/control/deconstruction_log")) add(require("modules/exp_scenario/control/degrading_tiles")) add(require("modules/exp_scenario/control/discord_alerts")) +add(require("modules/exp_scenario/control/features")) add(require("modules/exp_scenario/control/extra_logging")) add(require("modules/exp_scenario/control/fast_deconstruction")) add(require("modules/exp_scenario/control/help_bubbles")) @@ -58,6 +57,7 @@ add(require("modules/exp_scenario/control/inventory_clear")) add(require("modules/exp_scenario/control/mine_depletion")) add(require("modules/exp_scenario/control/nuke_protection")) add(require("modules/exp_scenario/control/pollution_grading")) +add(require("modules/exp_scenario/control/popups")) add(require("modules/exp_scenario/control/protection")) add(require("modules/exp_scenario/control/protection_jail")) add(require("modules/exp_scenario/control/report_jail")) diff --git a/exp_scenario/module/control/afk_kick.lua b/exp_scenario/module/control/afk_kick.lua index 24f9b378..2f213e71 100644 --- a/exp_scenario/module/control/afk_kick.lua +++ b/exp_scenario/module/control/afk_kick.lua @@ -3,8 +3,19 @@ Kicks players when all players on the server are afk ]] local Async = require("modules/exp_util/async") +local Feature = require("modules/exp_scenario/features") +local Roles = require("modules/exp_roles") local Storage = require("modules/exp_util/storage") -local config = require("modules.exp_legacy.config.afk_kick") +local feature, config = Feature.register("afk_kick", { + admin_as_active = true, -- When true admins will be treated as active regardless of afk time + trust_as_active = true, -- When true trusted players (by playtime) will be treated as active regardless of afk time + active_role_id = Feature.optional("number"), -- Players with this role or higher are treated as active regardless of afk time + afk_minutes = 10, -- The time in minutes that must pass for a player to be considered afk + kick_minutes = 30, -- The time in minutes that must pass without any active players for all players to be kicked + trust_minutes = 600, -- The time in minutes that a player must be online for to count as trusted +}) + +local ticks_per_minute = 3600 --- @type { last_active: number } local script_data = { last_active = 0 } @@ -15,17 +26,25 @@ end) --- Kicks an afk player, used to add a delay so the gui has time to appear local afk_kick_player_async = Async.register(function(player) - if game.tick - script_data.last_active < config.kick_time then return end + if game.tick - script_data.last_active < config.kick_minutes * ticks_per_minute then return end game.kick_player(player, "AFK while no active players on the server") end) +--- Check if 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_time + if player.afk_time < config.afk_minutes * ticks_per_minute or config.admin_as_active and player.admin - or config.trust_as_active and player.online_time > config.trust_time - or config.custom_active_check and config.custom_active_check(player) then + or config.trust_as_active and player.online_time > config.trust_minutes * ticks_per_minute + or is_active_role(player) then script_data.last_active = game.tick return true end @@ -40,7 +59,7 @@ local function check_afk_players() if has_active_player() then return end -- Check if players should be kicked - if game.tick - script_data.last_active < config.kick_time then return end + if game.tick - script_data.last_active < config.kick_minutes * ticks_per_minute then return end -- Kick time exceeded, kick all players for _, player in ipairs(game.connected_players) do @@ -73,12 +92,12 @@ end local e = defines.events -return { +return feature:guard{ events = { [e.on_player_joined_game] = on_player_joined_game, }, on_nth_tick = { - [config.update_time] = check_afk_players, + [60 * 60] = check_afk_players, }, has_active_player = has_active_player, } diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index 4e60ca40..1e1b6348 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -2,10 +2,10 @@ Adds auto replies to chat messages, as well as chat commands ]] +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.chat_reply") -local prefix = config.command_prefix -local prefix_len = string.len(prefix) +local feature = require("modules/exp_scenario/config/chat_auto_reply") +local config = feature.config local find = string.find local sub = string.sub @@ -16,11 +16,12 @@ local function on_console_chat(event) if not event.player_index then return end local player = assert(game.get_player(event.player_index)) local message = event.message:lower():gsub("%s+", "") + local prefix = config.command_prefix + local prefix_len = string.len(prefix) -- Check if the player can chat commands local commands_allowed = true - 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 @@ -56,8 +57,8 @@ end local e = defines.events -return { +return feature:guard{ events = { [e.on_console_chat] = on_console_chat, - } + }, } diff --git a/exp_scenario/module/control/chat_popup.lua b/exp_scenario/module/control/chat_popup.lua deleted file mode 100644 index a858acf3..00000000 --- a/exp_scenario/module/control/chat_popup.lua +++ /dev/null @@ -1,48 +0,0 @@ ---[[-- Control - Chat Popup -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 lower = string.lower -local find = string.find - ---- Create a chat bubble when a player types a message ---- @param event EventData.on_console_chat -local function on_console_chat(event) - if not event.player_index then return end - local player = assert(game.get_player(event.player_index)) - local name = player.name - - -- Sends the message as text above them - if config.show_player_messages then - FlyingText.create_as_player{ - target_player = player, - text = { "exp_chat-popup.flying-text-message", name, event.message }, - } - end - - if not config.show_player_mentions then return end - - -- Loops over online players to see if they name is included - local search_string = lower(event.message) - for _, mentioned_player in ipairs(game.connected_players) do - if mentioned_player.index ~= player.index then - if find(search_string, lower(mentioned_player.name), 1, true) then - FlyingText.create_as_player{ - target_player = mentioned_player, - text = { "exp_chat-popup.flying-text-ping", name }, - } - end - end - end -end - -local e = defines.events - -return { - events = { - [e.on_console_chat] = on_console_chat, - } -} diff --git a/exp_scenario/module/control/damage_popups.lua b/exp_scenario/module/control/damage_popups.lua deleted file mode 100644 index 803f95b6..00000000 --- a/exp_scenario/module/control/damage_popups.lua +++ /dev/null @@ -1,51 +0,0 @@ ---[[-- Control - Damage PopUps -Displays the amount of dmg that is done by players to entities; -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 random = math.random -local floor = math.floor -local max = math.max - ---- Called when entity entity is damaged including the player character ---- @param event EventData.on_entity_damaged -local function on_entity_damaged(event) - local message - local cause = event.cause - local entity = event.entity - - -- Check which message to display - if config.show_player_health and entity.name == "character" then - message = { "exp_damage-popup.flying-text-health", floor(entity.health) } - elseif config.show_player_damage and entity.name ~= "character" and cause and cause.name == "character" then - message = { "exp_damage-popup.flying-text-damage", floor(event.original_damage_amount) } - end - - -- Outputs the message as floating text - if message then - local entity_position = entity.position - local entity_radius = max(1, entity.get_radius()) - local offset = (random() - 0.5) * entity_radius * config.damage_location_variance - local position = { x = entity_position.x + offset, y = entity_position.y - entity_radius } - - local health_percentage = assert(entity.get_health_ratio()) - local color = { r = 1 - health_percentage, g = health_percentage, b = 0 } - - FlyingText.create{ - text = message, - position = position, - color = color, - } - end -end - -local e = defines.events - -return { - events = { - [e.on_entity_damaged] = on_entity_damaged, - }, -} diff --git a/exp_scenario/module/control/death_markers.lua b/exp_scenario/module/control/death_markers.lua index 24a74f30..e293a080 100644 --- a/exp_scenario/module/control/death_markers.lua +++ b/exp_scenario/module/control/death_markers.lua @@ -3,8 +3,18 @@ Makes markers on the map where places have died and reclaims items if not recove ]] local ExpUtil = require("modules/exp_util") +local Feature = require("modules/exp_scenario/features") local Storage = require("modules/exp_util/storage") -local config = require("modules.exp_legacy.config.death_logger") + +local feature, config = Feature.register("death_markers", { + collect_corpses = true, -- enables items being returned to the spawn point in chests upon corpse expiring + show_map_markers = true, -- shows markers on the map where bodies are + clean_map_markers = false, -- removes the map marker once the body is gone + include_time_of_death = true, -- weather to include the time of death on the map marker + map_icon = nil, -- the icon that the map marker shows; nil means no icon; format as a SingleID + show_light_at_corpse = true, -- if a light should be rendered at the corpse + show_line_to_corpse = true, -- if a line should be rendered from you to your corpse +}) local map_tag_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true } @@ -40,6 +50,7 @@ end --- Checks that all map tags are present and valid, creating any that are missing local function check_map_tags() + if not config.show_map_markers then return end for _, corpse_data in pairs(character_corpses) do if not corpse_data.tag or not corpse_data.tag.valid then create_map_tag(corpse_data) @@ -98,6 +109,7 @@ end --- Draw lines to the player corpse --- @param event EventData.on_player_respawned local function on_player_respawned(event) + if not config.show_line_to_corpse then return end local index = event.player_index local player = assert(game.get_player(index)) for _, corpse_data in pairs(character_corpses) do @@ -122,6 +134,7 @@ end --- Collect all items from expired character corpses --- @param event EventData.on_character_corpse_expired local function on_character_corpse_expired(event) + if not config.collect_corpses then return end local corpse = event.corpse local inventory = assert(corpse.get_inventory(defines.inventory.character_corpse)) ExpUtil.transfer_inventory_to_surface{ @@ -132,26 +145,16 @@ local function on_character_corpse_expired(event) } end -local on_nth_tick = {} -if config.show_map_markers then - on_nth_tick[config.period_check_map_tags] = check_map_tags -end - local e = defines.events -local events = { - [e.on_player_died] = on_player_died, - [e.on_object_destroyed] = on_object_destroyed, -} -if config.show_line_to_corpse then - events[e.on_player_respawned] = on_player_respawned -end - -if config.collect_corpses then - events[e.on_character_corpse_expired] = on_character_corpse_expired -end - -return { - on_nth_tick = on_nth_tick, - events = events, +return feature:guard{ + on_nth_tick = { + [60 * 60 * 5] = check_map_tags, + }, + events = { + [e.on_player_died] = on_player_died, + [e.on_object_destroyed] = on_object_destroyed, + [e.on_player_respawned] = on_player_respawned, + [e.on_character_corpse_expired] = on_character_corpse_expired, + }, } diff --git a/exp_scenario/module/control/discord_alerts.lua b/exp_scenario/module/control/discord_alerts.lua index 6b700341..064af60a 100644 --- a/exp_scenario/module/control/discord_alerts.lua +++ b/exp_scenario/module/control/discord_alerts.lua @@ -4,7 +4,34 @@ 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 Feature = require("modules/exp_scenario/features") +local EntityProtection = require("modules/exp_scenario/control/protection") +local Jail = require("modules/exp_scenario/control/jail") + +local feature, config = Feature.register("discord_alerts", { + show_playtime = true, -- Add the playtime of players to alerts + entity_protection = true, -- Alert when a player repeatedly removes protected entities + player_bans = true, -- Alert when a player is banned or unbanned + player_mutes = true, -- Alert when a player is muted or unmuted + player_kicks = true, -- Alert when a player is kicked + player_promotes = false, -- Alert when a player is promoted or demoted + player_jail = true, -- Alert when a player is jailed or unjailed + logged_commands = Feature.set{ -- Alert when a player uses one of these commands + "config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat", + }, +}) + +--- 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 +107,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 config.logged_commands[event.command] then emit_event{ title = event.command:gsub("^%l", string.upper), description = "/" .. event.command .. " was used", @@ -259,6 +272,6 @@ events[e.on_console_command] = function(event) end end -return { +return feature:guard{ events = events, } diff --git a/exp_scenario/module/control/features.lua b/exp_scenario/module/control/features.lua new file mode 100644 index 00000000..738ddc38 --- /dev/null +++ b/exp_scenario/module/control/features.lua @@ -0,0 +1,21 @@ +--[[-- Control - Features +Refreshes the gui of every player when a feature is enabled or disabled, see features.lua +]] + +local Feature = require("modules/exp_scenario/features") +local Gui = require("modules/exp_gui") + +--- Toolbar buttons of disabled features are hidden, which also hides their left elements +--- @param event EventData.ExpScenario.on_config_updated +local function on_config_updated(event) + if event.path ~= "enabled" then return end + for _, player in pairs(game.connected_players) do + Gui._ensure_consistency{ player_index = player.index } + end +end + +return { + events = { + [Feature.events.on_config_updated] = on_config_updated, + }, +} diff --git a/exp_scenario/module/control/mine_depletion.lua b/exp_scenario/module/control/mine_depletion.lua index f8a6cb55..77efb618 100644 --- a/exp_scenario/module/control/mine_depletion.lua +++ b/exp_scenario/module/control/mine_depletion.lua @@ -3,7 +3,13 @@ Marks mining drills for deconstruction when resources deplete ]] local Async = require("modules/exp_util/async") -local config = require("modules.exp_legacy.config.miner") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("mine_depletion", { + fluid = true, -- When true, checks for for fluid pipes when removing miners + chest = true, -- When true, checks for for chest when removing miners + beacon = true, -- When true, checks for for beacon when removing miners +}) local floor = math.floor @@ -270,7 +276,7 @@ end local e = defines.events -return { +return feature:guard{ events = { [e.on_resource_depleted] = on_resource_depleted, }, diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index ba8f4320..e8638ce4 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -3,20 +3,32 @@ Disable new players from having certain items in their inventory, most commonly ]] local ExpUtil = require("modules/exp_util") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.nukeprotect") + +local feature, config = Feature.register("nuke_protection", { + banned_items = Feature.set{ "atomic-bomb" }, -- Items which are removed from the inventory of players without the bypass permission +}) + +--- 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 config.ignore_admins and player.admin then return end + if Roles.player_has_permission(player, "exp_scenario.bypass.nuke_protection") then return end + local banned_items = config.banned_items local items = {} --- @type LuaItemStack[] - local inventory = assert(player.get_inventory(type)) + local inventory = player.get_inventory(type) + if not inventory then return end -- Check what items the player has for i = 1, #inventory do local item = inventory[i --[[@as uint]]] @@ -37,20 +49,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 feature:guard{ events = events, } diff --git a/exp_scenario/module/control/popups.lua b/exp_scenario/module/control/popups.lua new file mode 100644 index 00000000..f25b5fba --- /dev/null +++ b/exp_scenario/module/control/popups.lua @@ -0,0 +1,92 @@ +--[[-- Control - Popups +Creates flying text for chat messages and for damage dealt to and by players +]] + +local FlyingText = require("modules/exp_util/flying_text") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("popups", { + show_player_messages = true, -- weather a message in chat will make a popup above them + show_player_mentions = true, -- weather a mentioned player will have a popup when mentioned in chat + show_player_damage = true, -- weather to show damage done by players + show_player_health = true, -- weather to show player health when attacked + damage_location_variance = 0.8, -- how close to the eade of an entity the popups will appear +}) + +local lower = string.lower +local find = string.find +local random = math.random +local floor = math.floor +local max = math.max + +--- Create a chat bubble when a player types a message +--- @param event EventData.on_console_chat +local function on_console_chat(event) + if not event.player_index then return end + local player = assert(game.get_player(event.player_index)) + local name = player.name + + -- Sends the message as text above them + if config.show_player_messages then + FlyingText.create_as_player{ + target_player = player, + text = { "exp_chat-popup.flying-text-message", name, event.message }, + } + end + + if not config.show_player_mentions then return end + + -- Loops over online players to see if they name is included + local search_string = lower(event.message) + for _, mentioned_player in ipairs(game.connected_players) do + if mentioned_player.index ~= player.index then + if find(search_string, lower(mentioned_player.name), 1, true) then + FlyingText.create_as_player{ + target_player = mentioned_player, + text = { "exp_chat-popup.flying-text-ping", name }, + } + end + end + end +end + +--- Called when entity entity is damaged including the player character +--- @param event EventData.on_entity_damaged +local function on_entity_damaged(event) + local message + local cause = event.cause + local entity = event.entity + + -- Check which message to display + if config.show_player_health and entity.name == "character" then + message = { "exp_damage-popup.flying-text-health", floor(entity.health) } + elseif config.show_player_damage and entity.name ~= "character" and cause and cause.name == "character" then + message = { "exp_damage-popup.flying-text-damage", floor(event.original_damage_amount) } + end + + -- Outputs the message as floating text + if message then + local entity_position = entity.position + local entity_radius = max(1, entity.get_radius()) + local offset = (random() - 0.5) * entity_radius * config.damage_location_variance + local position = { x = entity_position.x + offset, y = entity_position.y - entity_radius } + + local health_percentage = assert(entity.get_health_ratio()) + local color = { r = 1 - health_percentage, g = health_percentage, b = 0 } + + FlyingText.create{ + text = message, + position = position, + color = color, + } + end +end + +local e = defines.events + +return feature:guard{ + events = { + [e.on_console_chat] = on_console_chat, + [e.on_entity_damaged] = on_entity_damaged, + }, +} diff --git a/exp_scenario/module/control/protection.lua b/exp_scenario/module/control/protection.lua index 9839290f..324c9f79 100644 --- a/exp_scenario/module/control/protection.lua +++ b/exp_scenario/module/control/protection.lua @@ -3,8 +3,21 @@ Protects entities and areas from being mined by players other than the one who p ]] local Storage = require("modules/exp_util/storage") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.protection") + +local feature, config = Feature.register("protection", { + repeat_count = 5, -- Number of protected entities that must be removed within repeat_minutes in order to trigger repeated removal protection + repeat_minutes = 20, -- The length of time, in minutes, that protected removals will be remembered for + always_protected_names = Feature.set{}, -- Names of entities which are always protected + always_protected_types = Feature.set{ -- 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 = Feature.set{}, -- Names of entities which always trigger repeated removal protection + always_trigger_repeat_types = Feature.set{ -- Types of entities which always trigger repeated removal protection + "reactor", "fusion-reactor", "rocket-silo", + }, +}) local format_string = string.format local floor = math.floor @@ -17,12 +30,8 @@ local Protection = { --- Raised when a player mines protected entities repeatedly, or one which always counts as repeated --- @type EventData.ExpScenario_Protection.on_repeat_violation on_repeat_violation = script.generate_event_name(), - --- Names of entities which are always protected - --- @type string[] - protected_entity_names = config.always_protected_names, - --- Types of entities which are always protected - --- @type string[] - protected_entity_types = config.always_protected_types, + --- The config of the feature, read only + config = config, --- @package events = {}, --- @package @@ -36,33 +45,29 @@ local Protection = { --- @field last uint Tick of the last protected removal --- @field count number Protected removals since the last repeat violation ---- @param values string[] ---- @return table -local function to_set(values) - local set = {} - for _, value in ipairs(values) do - set[value] = true - end - return set -end +--- @class ExpScenario_Protection.Registration +--- @field surface_index uint +--- @field key string +--- @field entity LuaEntity -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 protected_entities = {} --- @type table> Keyed by surface index then entity key -local protected_areas = {} --- @type table> Keyed by surface index then area key -local repeats = {} --- @type table Keyed by player name +local protected_entities = {} --- @type table> By surface index then entity key +local protected_areas = {} --- @type table> By surface index then area key +local registrations = {} --- @type table By on_object_destroyed registration number +local repeats = {} --- @type table By player name Storage.register({ protected_entities = protected_entities, protected_areas = protected_areas, + registrations = registrations, repeats = repeats, }, function(tbl) protected_entities = tbl.protected_entities protected_areas = tbl.protected_areas + registrations = tbl.registrations repeats = tbl.repeats +end, function(tbl) + -- Saves from before entities were registered + tbl.registrations = tbl.registrations or {} end) --- Get the key an entity is stored under @@ -79,15 +84,18 @@ function Protection.get_area_key(area) return format_string("%i,%i", floor(area.left_top.x), floor(area.left_top.y)) end ---- Protect an entity +--- Protect an entity, it is forgotten when destroyed --- @param entity LuaEntity function Protection.add_entity(entity) - local entities = protected_entities[entity.surface.index] + local surface_index = entity.surface.index + local entities = protected_entities[surface_index] if not entities then entities = {} - protected_entities[entity.surface.index] = entities + protected_entities[surface_index] = entities end - entities[Protection.get_entity_key(entity)] = entity + local key = Protection.get_entity_key(entity) + entities[key] = entity + registrations[script.register_on_object_destroyed(entity)] = { surface_index = surface_index, key = key, entity = entity } end --- Remove the protection from an entity @@ -95,7 +103,11 @@ end function Protection.remove_entity(entity) local entities = protected_entities[entity.surface.index] if not entities then return end - entities[Protection.get_entity_key(entity)] = nil + local key = Protection.get_entity_key(entity) + if entities[key] == entity then + entities[key] = nil + registrations[script.register_on_object_destroyed(entity)] = nil + end end --- Get the protected entities on a surface, always protected entities are not included @@ -109,7 +121,7 @@ end --- @param entity LuaEntity --- @return boolean function Protection.is_entity_protected(entity) - if always_protected_names[entity.name] or always_protected_types[entity.type] then return true end + if config.always_protected_names[entity.name] or config.always_protected_types[entity.type] then return true end local entities = protected_entities[entity.surface.index] if not entities then return false end return entities[Protection.get_entity_key(entity)] == entity @@ -161,14 +173,13 @@ function Protection.is_position_protected(surface, position) return false end ---- Players are never checked against their own entities, and can be excluded by permission or admin status +--- Players are never checked against their own entities, and can be excluded by permission --- @param player LuaPlayer --- @param entity LuaEntity --- @return boolean 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 @@ -187,14 +198,14 @@ local function raise_violation(event, player) script.raise_event(Protection.on_player_mined_protected, event) local entity = event.entity - local always_repeat = always_trigger_repeat_names[entity.name] or always_trigger_repeat_types[entity.type] + local always_repeat = config.always_trigger_repeat_names[entity.name] or config.always_trigger_repeat_types[entity.type] if always_repeat or player_repeats.count >= config.repeat_count then player_repeats.count = 0 script.raise_event(Protection.on_repeat_violation, event) end end ---- Raise the protection events when a protected entity is mined, then forget the entity +--- Raise the protection events when a protected entity is mined --- @param event EventData.on_pre_player_mined_item local function on_pre_player_mined_item(event) local entity = event.entity @@ -205,19 +216,41 @@ local function on_pre_player_mined_item(event) then raise_violation(event, player) end - - Protection.remove_entity(entity) end ---- Forget an entity once it no longer exists ---- @param event { entity: LuaEntity } -local function on_entity_removed(event) - Protection.remove_entity(event.entity) +--- Forget a protected entity once it is destroyed, unless another one took its place +--- @param registration_number uint64 +--- @param registration ExpScenario_Protection.Registration +local function forget_entity(registration_number, registration) + registrations[registration_number] = nil + local entities = protected_entities[registration.surface_index] + if entities and entities[registration.key] == registration.entity then + entities[registration.key] = nil + end +end + +--- @param event EventData.on_object_destroyed +local function on_object_destroyed(event) + local registration = registrations[event.registration_number] + if registration then + forget_entity(event.registration_number, registration) + end +end + +--- Entities destroyed while the feature was disabled were not forgotten +--- @param event EventData.ExpScenario.on_config_updated +local function on_config_updated(event) + if event.feature_name ~= feature.name or event.path ~= "enabled" then return end + for registration_number, registration in pairs(registrations) do + if not registration.entity.valid then + forget_entity(registration_number, registration) + end + end 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 @@ -228,10 +261,8 @@ end local e = defines.events Protection.events[e.on_pre_player_mined_item] = on_pre_player_mined_item -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.events[e.on_object_destroyed] = on_object_destroyed +Protection.events[Feature.events.on_config_updated] = on_config_updated +Protection.on_nth_tick[3600 * 5] = clear_old_repeats -return Protection +return feature:guard(Protection) diff --git a/exp_scenario/module/control/spawn_area.lua b/exp_scenario/module/control/spawn_area.lua index d4c23275..18134c35 100644 --- a/exp_scenario/module/control/spawn_area.lua +++ b/exp_scenario/module/control/spawn_area.lua @@ -2,7 +2,9 @@ Adds a custom spawn area with chests and afk turrets ]] -local config = require("modules.exp_legacy.config.spawn_area") +local Feature = require("modules/exp_scenario/features") +local feature = require("modules/exp_scenario/config/spawn_area") +local config = feature.config --- Apply an offset to a LuaPosition --- @param position MapPosition.struct @@ -33,7 +35,7 @@ end apply_offset_to_array(config.turrets.locations, config.turrets.offset, 1, 2) apply_offset_to_array(config.afk_belts.locations, config.afk_belts.offset, 1, 2) apply_offset_to_array(config.water.locations, config.water.offset, 1, 2) -apply_offset_to_array(config.water.locations, config.water.offset, 1, 2) +apply_offset_to_array(config.pattern.locations, config.pattern.offset, 1, 2) apply_offset_to_array(config.entities.locations, config.entities.offset, 2, 3) --- Get or create the force used for entities in spawn @@ -251,30 +253,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 +300,9 @@ end local e = defines.events -return { +return feature:guard{ on_nth_tick = on_nth_tick, events = { [e.on_player_created] = on_player_created, - } + }, } diff --git a/exp_scenario/module/control/station_auto_name.lua b/exp_scenario/module/control/station_auto_name.lua index 96b8fc09..061cc7ab 100644 --- a/exp_scenario/module/control/station_auto_name.lua +++ b/exp_scenario/module/control/station_auto_name.lua @@ -2,7 +2,13 @@ Automatically name stations when they are placed based on closest resource and direction from spawn ]] -local config = require("modules.exp_legacy.config.station_auto_name") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("station_auto_name", { + -- the name given to new stations, placeholders: + -- __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__ + station_name = "[L] __icon__", +}) local get_direction do local directions = { @@ -98,9 +104,9 @@ end local e = defines.events -return { +return feature:guard{ events = { [e.on_built_entity] = rename_station, [e.on_robot_built_entity] = rename_station, - } + }, } diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua new file mode 100644 index 00000000..1c25096c --- /dev/null +++ b/exp_scenario/module/features.lua @@ -0,0 +1,282 @@ +--[[-- ExpScenario - Features +Features which the controller can enable, disable, and configure while the game is running + +The config table is changed in place when the controller sends values, so read +them where they are used rather than caching them when the file loads. Values +read while the file loads, such as on_nth_tick periods, are not listed in features.ts. + +--- Registering a feature, small configs live at the top of the file which uses them: +local Feature = require("modules/exp_scenario/features") +local feature, config = Feature.register("my_feature", { + show_message = true, -- booleans, numbers, and strings + role_id = Feature.optional("number"), -- no default + item_names = Feature.set{ "iron-plate" }, -- indexed by name + surface_names = Feature.list{ "nauvis" }, + section = { count = 1 }, -- addressed as "section.count" +}) + +--- Handlers do nothing while the feature is disabled: +return feature:guard{ + events = { [defines.events.on_player_joined_game] = on_player_joined_game }, +} + +--- Toolbar buttons and commands: +Gui.toolbar.create_button{ visible = function(player) return feature:is_enabled() end } +Commands.new("my_command", { "..." }):add_flags{ feature = feature } + +--- Changes are raised per value: +[Feature.events.on_config_updated] = function(event) end, -- { feature_name, path, old_value, new_value } +]] + +local Storage = require("modules/exp_util/storage") + +--- @class ExpScenario.FeatureField +--- @field path string +--- @field parent table +--- @field key string +--- @field default any +--- @field type string "boolean", "number", "string", "list", "set", or a type which can not be set + +--- @class ExpScenario.Features +local Feature = { + _registered = {}, --- @type table + events = { + --- @type EventData.ExpScenario.on_config_updated + on_config_updated = script.generate_event_name(), + }, +} + +--- @class ExpScenario.Feature +--- @field name string +--- @field config table +--- @field fields table By path +--- @field values table Last received from the controller, restored from storage on load +Feature._prototype = {} + +--- @class EventData.ExpScenario.on_config_updated : EventData +--- @field feature_name string +--- @field path string "enabled" when the feature is enabled or disabled +--- @field old_value any +--- @field new_value any + +local Feature_mt = { + __index = Feature._prototype, +} + +--- Only the values are stored, the rest is rebuilt when the file loads +local stored_values = {} --- @type table> +Storage.register(stored_values, function(tbl) + stored_values = tbl + for name, feature in pairs(Feature._registered) do + feature.values = tbl[name] or {} + for path, field in pairs(feature.fields) do + feature:_set_field(field, feature.values[path]) + end + end +end) + +--- @param value any +--- @return boolean +local function is_string_list(value) + if type(value) ~= "table" then return false end + if next(value) ~= nil and value[1] == nil then return false end + for _, item in pairs(value) do + if type(item) ~= "string" then return false end + end + return true +end + +--- Lists and sets are compared by their contents +--- @param a any +--- @param b any +--- @return boolean +local function values_equal(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + if table_size(a) ~= table_size(b) then return false end + for key, value in pairs(a) do + if b[key] ~= value then return false end + end + return true +end + +--- Marks a default made by Feature.list, Feature.set, or Feature.optional +local typed_mt = {} + +--- @param list string[] +--- @return table +local function list_to_set(list) + local set = {} + for _, item in ipairs(list) do + set[item] = true + end + return set +end + +--- Nested tables with string keys are sections +--- @param fields table +--- @param tbl table +--- @param prefix string +local function collect_fields(fields, tbl, prefix) + for key, value in pairs(tbl) do + if type(key) == "string" then + local path = prefix .. key + local field = { path = path, parent = tbl, key = key, default = value, type = type(value) } + if type(value) == "table" then + if getmetatable(value) == typed_mt then + field.type = value.type + field.default = value.default + tbl[key] = value.default + elseif type(next(value)) == "string" then + collect_fields(fields, value, path .. ".") + field = nil + end + end + if field then + fields[path] = field + end + end + end +end + +--- A value with no default, the argument is the type it accepts +--- @param value_type "boolean" | "number" | "string" +--- @return any +function Feature.optional(value_type) + return setmetatable({ type = value_type }, typed_mt) +end + +--- A list of strings +--- @param items string[] +--- @return string[] +function Feature.list(items) + return setmetatable({ type = "list", default = items }, typed_mt) +end + +--- A set of strings, the controller sends a list +--- @param items string[] +--- @return table +function Feature.set(items) + return setmetatable({ type = "set", default = list_to_set(items) }, typed_mt) +end + +--- Features are enabled unless the config says otherwise +--- @param name string +--- @param config table? +--- @return ExpScenario.Feature feature +--- @return table config +function Feature.register(name, config) + assert(Feature._registered[name] == nil, "Feature already registered: " .. name) + config = config or {} + if config.enabled == nil then + config.enabled = true + end + + local fields = {} + collect_fields(fields, config, "") + + local feature = setmetatable({ + name = name, + config = config, + fields = fields, + values = {}, + }, Feature_mt) --[[@as ExpScenario.Feature]] + + Feature._registered[name] = feature + return feature, config +end + +--- @param name string +--- @return ExpScenario.Feature +function Feature.get(name) + return Feature._registered[name] or error("Unknown feature: " .. tostring(name), 2) +end + +--- @param field ExpScenario.FeatureField +--- @param value any +--- @return boolean +local function is_valid(field, value) + if field.type == "list" or field.type == "set" then + return is_string_list(value) + end + return type(value) == field.type and (field.type == "boolean" or field.type == "number" or field.type == "string") +end + +--- Set a value, nil or an invalid value means the default +--- @package +--- @param field ExpScenario.FeatureField +--- @param value any +--- @return any # The value which was set +function Feature._prototype:_set_field(field, value) + if value ~= nil and not is_valid(field, value) then + log("[WARNING] Invalid value for " .. self.name .. "." .. field.path .. ", using the default") + value = nil + end + if value == nil then + value = field.default + elseif field.type == "set" then + value = list_to_set(value) + end + field.parent[field.key] = value + return value +end + +--- @return boolean +function Feature._prototype:is_enabled() + return self.config.enabled +end + +--- Make the event and on_nth_tick handlers of a lib do nothing while the feature is disabled +--- @generic T : table +--- @param lib T +--- @return T +function Feature._prototype:guard(lib) + local config = self.config + for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do + for key, handler in pairs(handlers) do + handlers[key] = function(event) + if config.enabled then + return handler(event) + end + end + end + end + + return lib +end + +--- Replace every value, then raise on_config_updated for each one which changed +--- @param values table By path, "enabled" included +function Feature._prototype:update_config(values) + self.values = values + stored_values[self.name] = values + + local updates = {} + for path, field in pairs(self.fields) do + local old_value = field.parent[field.key] + local new_value = self:_set_field(field, values[path]) + if not values_equal(old_value, new_value) then + updates[#updates + 1] = { feature_name = self.name, path = path, old_value = old_value, new_value = new_value } + end + end + + for _, update in ipairs(updates) do + script.raise_event(Feature.events.on_config_updated, update) + end +end + +--- @class ExpScenario.FeatureUpdate +--- @field name string +--- @field values table + +--- Called over rcon by the instance plugin, unknown features are ignored +--- @param updates ExpScenario.FeatureUpdate[] +function Feature.receive_update(updates) + for _, update in ipairs(updates) do + local feature = Feature._registered[update.name] + if feature then + feature:update_config(update.values) + end + end +end + +return Feature diff --git a/exp_scenario/module/globals.lua b/exp_scenario/module/globals.lua new file mode 100644 index 00000000..bdb9ef6a --- /dev/null +++ b/exp_scenario/module/globals.lua @@ -0,0 +1,6 @@ +--- @diagnostic disable: global-in-non-module + +-- Access using `/sc exp_scenario.features.foo()` +exp_scenario = { + features = require("modules/exp_scenario/features"), +} diff --git a/exp_scenario/module/gui/autofill.lua b/exp_scenario/module/gui/autofill.lua index 8c663af4..9a6b7093 100644 --- a/exp_scenario/module/gui/autofill.lua +++ b/exp_scenario/module/gui/autofill.lua @@ -2,9 +2,12 @@ Adds a config menu for setting autofill of placed entities ]] +local Feature = require("modules/exp_scenario/features") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.gui.autofill") + +local feature = Feature.register("autofill") local FlyingText = require("modules/exp_util/flying_text") local min = math.min @@ -330,7 +333,7 @@ Gui.toolbar.create_button{ sprite = config.icon, tooltip = { "exp-gui_autofill.tooltip-main" }, visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.autofill") + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.autofill") end } @@ -396,9 +399,9 @@ end local e = defines.events -return { +return feature:guard{ elements = Elements, events = { [e.on_built_entity] = on_built_entity, - } + }, } diff --git a/exp_scenario/module/gui/quick_actions.lua b/exp_scenario/module/gui/quick_actions.lua index 2d9c7cdb..28c9ec37 100644 --- a/exp_scenario/module/gui/quick_actions.lua +++ b/exp_scenario/module/gui/quick_actions.lua @@ -4,6 +4,7 @@ Adds a few buttons for common actions local Gui = require("modules/exp_gui") local Commands = require("modules/exp_commands") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local addon_artillery = require("modules/exp_scenario/commands/artillery") @@ -126,9 +127,19 @@ local function on_role_changed(event) Elements.container.refresh_player(player) end +--- Actions for commands of a disabled feature are hidden +--- @param event EventData.ExpScenario.on_config_updated +local function on_config_updated(event) + if event.path ~= "enabled" then return end + for _, player in pairs(game.connected_players) do + Elements.container.refresh_player(player) + end +end + return { elements = Elements, events = { [Roles.events.on_player_roles_changed] = on_role_changed, + [Feature.events.on_config_updated] = on_config_updated, } } diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 4d3fe9db..1917b254 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -1,6 +1,9 @@ [exp-commands-authorities_role] deny=Unauthorized, Access is denied due to missing permissions. +[exp-commands-authorities_feature] +deny=This command is disabled on this server. + [exp-commands-parse_role] lower-role=Role is higher than your highest. lower-role-player=Player has a higher role. diff --git a/exp_scenario/module/module.json b/exp_scenario/module/module.json index 34082135..2b2d40e9 100644 --- a/exp_scenario/module/module.json +++ b/exp_scenario/module/module.json @@ -3,6 +3,7 @@ "load": [ ], "require": [ + "globals.lua", "control.lua" ], "dependencies": { diff --git a/exp_scenario/package.json b/exp_scenario/package.json index 706fef28..191b13be 100644 --- a/exp_scenario/package.json +++ b/exp_scenario/package.json @@ -17,15 +17,18 @@ }, "peerDependencies": { "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^" }, "devDependencies": { "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", "@types/node": "catalog:", "@types/react": "catalog:", "antd": "catalog:", + "fengari": "^0.1.5", "react": "catalog:", "react-dom": "catalog:", "tap": "^21.1.0", diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index befc28df..3c9916b0 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -4,6 +4,7 @@ import { Controller } from "@clusterio/controller"; import { ControllerPlugin } from "../dist/node/controller.js"; import * as messages from "../dist/node/messages.js"; import { seedRoles, seedGroups } from "../dist/node/seed.js"; +import { features } from "../dist/node/features.js"; import * as roles from "@expcluster/roles"; import * as groups from "@expcluster/permission-groups"; import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js"; @@ -16,7 +17,7 @@ import { plugin } from "../dist/node/index.js"; lib.registerPluginPermissions([plugin]); // The controller validates message classes against the link registry -for (const Message of [messages.SeedRequest, ...roles.plugin.messages, ...groups.plugin.messages]) { +for (const Message of [...plugin.messages, ...roles.plugin.messages, ...groups.plugin.messages]) { lib.Link.register(Message); } @@ -29,9 +30,9 @@ t.after(() => { }); /** Build the plugin around a real controller, which is side effect free while not started. */ -async function startPlugin(t2, { withPlugins = true } = {}) { +async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.testdir() } = {}) { const controllerConfig = new lib.ControllerConfig("controller", { - "controller.database_directory": t2.testdir(), + "controller.database_directory": databaseDirectory, "controller.default_role_id": lib.Role.DefaultPlayerRoleId, }); const controller = new Controller(logger, [], controllerConfig); @@ -135,5 +136,61 @@ t.test("class ControllerPlugin", t2 => { t3.notOk([...first.values()].filter(priority => priority === 2).length > 1, "seed priorities skip taken ones"); }); + t2.test(".init() adds every feature with its defaults", async t3 => { + const { plugin } = await startPlugin(t3, { withPlugins: false }); + const records = await plugin.handleFeatureListRequest(); + t3.strictSame(records.map(record => record.id), features.map(feature => feature.name), "one record per feature"); + t3.ok(records.every(record => record.enabled && !Object.keys(record.values).length), "enabled with no overrides"); + }); + + t2.test(".reconcileFeatures() drops stored values and features which no longer exist", async t3 => { + const databaseDirectory = t3.testdir(); + const { plugin } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + plugin.features.set(new messages.FeatureRecord("afk_kick", false, { afk_minutes: 5, trust_time: 600 })); + plugin.features.set(new messages.FeatureRecord("gone", true, {})); + await plugin.onShutdown(); + + const { plugin: restarted } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + const record = restarted.features.get("afk_kick"); + t3.strictSame([record.enabled, record.values], [false, { afk_minutes: 5 }], "the stale value is dropped, the rest kept"); + t3.notOk(restarted.features.has("gone"), "the removed feature is deleted"); + t3.ok(features.every(feature => restarted.features.has(feature.name)), "every declared feature still has a record"); + }); + + t2.test(".handleFeatureUpdateRequest() stores the values which differ from the default", async t3 => { + const { plugin } = await startPlugin(t3, { withPlugins: false }); + const broadcasts = []; + plugin.controller.subscriptions.broadcast = event => broadcasts.push(event); + + const record = await plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest( + "death_markers", false, { show_map_markers: false, collect_corpses: true }, + )); + t3.strictSame(record.values, { show_map_markers: false }, "the default value is dropped"); + t3.equal(plugin.features.get("death_markers").enabled, false, "the feature is disabled"); + t3.strictSame(broadcasts.map(event => event.updates.map(update => update.id)), [["death_markers"]], "the update is broadcast"); + + const subscription = await plugin.handleFeatureSubscription({ lastRequestTimeMs: record.updatedAtMs - 1 }); + t3.strictSame(subscription.updates.map(update => update.id), ["death_markers"], "subscribers catch up on the change"); + }); + + t2.test(".handleFeatureUpdateRequest() rejects invalid values", async t3 => { + const { plugin } = await startPlugin(t3, { withPlugins: false }); + await t3.rejects( + plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest("death_markers", true, { unknown: 1 })), + { message: "Feature death_markers has no setting unknown" }, + ); + }); + + t2.test(".init() keeps stored features across restarts", async t3 => { + const databaseDirectory = t3.testdir(); + const { plugin } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + await plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 })); + await plugin.onShutdown(); + + const { plugin: restarted } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + const record = restarted.features.get("afk_kick"); + t3.strictSame([record.enabled, record.values], [false, { afk_minutes: 5 }], "the stored record is loaded"); + }); + t2.end(); }); diff --git a/exp_scenario/test/features.test.js b/exp_scenario/test/features.test.js new file mode 100644 index 00000000..928e855f --- /dev/null +++ b/exp_scenario/test/features.test.js @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import path from "node:path"; +import t from "tap"; +import { features, pruneFeatureValues, validateFeatureValues } from "../dist/node/features.js"; + +/** Every lua file in the module, by path relative to it. */ +function luaFiles(dir, prefix = "") { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const relative = path.posix.join(prefix, entry.name); + if (entry.isDirectory()) { + return luaFiles(path.join(dir, entry.name), relative); + } + return entry.name.endsWith(".lua") ? [[relative, fs.readFileSync(path.join(dir, entry.name), "utf8")]] : []; + }); +} + +const moduleFiles = luaFiles(path.join(import.meta.dirname, "..", "module")); + +/** + * Read a default from a Feature.register call, a path of "a.b" looks for b after the table a starts. + * Only covers booleans, numbers, strings, Feature.list, Feature.set, and Feature.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.]+|"[^"]*"|Feature\\.optional\\("\\w+"\\)|(?:Feature\\.(?:list|set))?\\{[^{}]*\\})`, "m") + .exec(source.slice(start)); + if (!match) { + return undefined; + } + const literal = match[1]; + if (literal.startsWith("Feature.optional")) { + return null; + } + if (literal.includes("{")) { + return [...literal.matchAll(/"([^"]*)"/g)].map(item => item[1]); + } + return JSON.parse(literal); +} + +t.test("features", t2 => { + for (const feature of features) { + t2.test(feature.name, t3 => { + const declarations = moduleFiles.filter(([, source]) => ( + source.includes(`Feature.register("${feature.name}"`) + )); + t3.equal(declarations.length, 1, "one lua file declares the feature"); + const source = declarations[0]?.[1] ?? ""; + for (const field of feature.fields) { + t3.strictSame(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`); + } + t3.end(); + }); + } + t2.end(); +}); + +t.test("validateFeatureValues()", t2 => { + t2.same( + validateFeatureValues("death_markers", { show_map_markers: false, collect_corpses: true }), + { show_map_markers: false }, + "values equal to their default are dropped", + ); + t2.throws(() => validateFeatureValues("unknown", {}), { message: "Unknown feature unknown" }); + t2.throws( + () => validateFeatureValues("death_markers", { map_icon: "x" }), + { message: "Feature death_markers has no setting map_icon" }, + ); + t2.throws( + () => validateFeatureValues("death_markers", { show_map_markers: "no" }), + { message: "Setting show_map_markers of death_markers must be a boolean" }, + ); + t2.throws( + () => validateFeatureValues("afk_kick", { afk_minutes: 0 }), + { message: "Setting afk_minutes of afk_kick must be at least 1" }, + ); + t2.throws( + () => validateFeatureValues("afk_kick", { afk_minutes: Infinity }), + { message: "Setting afk_minutes of afk_kick must be a finite number" }, + ); + t2.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", { afk_minutes: null }), + { message: "Setting afk_minutes of afk_kick can not be 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(); +}); + +t.test("pruneFeatureValues()", t2 => { + const afkKick = features.find(feature => feature.name === "afk_kick"); + t2.same( + pruneFeatureValues(afkKick, { afk_minutes: 5, trust_time: 600, kick_minutes: "soon", active_role_id: null }), + { kept: { afk_minutes: 5, active_role_id: null }, dropped: ["trust_time", "kick_minutes"] }, + "removed settings and wrong types are dropped, valid values and optional nulls stay", + ); + t2.end(); +}); diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js new file mode 100644 index 00000000..31beee6d --- /dev/null +++ b/exp_scenario/test/instance.test.js @@ -0,0 +1,124 @@ +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Instance } from "@clusterio/host"; +import { InstancePlugin } from "../dist/node/instance.js"; +import { plugin as pluginDeclaration } from "../dist/node/index.js"; +import * as messages from "../dist/node/messages.js"; +import { features } from "../dist/node/features.js"; + +// The instance validates message classes against the link registry +for (const Message of pluginDeclaration.messages) { + lib.Link.register(Message); +} + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +class TestConnector extends lib.BaseConnector { + constructor() { + super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 })); + this.valid = true; + this.connected = true; + this.hasSession = true; + } + + send() {} +} + +/** Build a plugin around a real instance with spies on what leaves it. */ +async function startPlugin(t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })]) { + const instanceConfig = new lib.InstanceConfig("host"); + instanceConfig.set("instance.id", 1); + instanceConfig.set("instance.name", "test"); + + const instance = new Instance( + { assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig + ); + + const state = { sent: [], rcons: [] }; + instance.server = { + handle: () => {}, + sendRcon: async command => { + state.rcons.push(command); + return ""; + }, + }; + instance.sendTo = async (dst, request) => { + state.sent.push(request); + return request instanceof messages.FeatureListRequest ? features : undefined; + }; + + const plugin = new InstancePlugin({ plugin: { name: "exp_scenario" }, instance, host: {}, logger }); + await plugin.init(); + return { plugin, instance, state }; +} + +/** The payload of a recorded rcon command. */ +function decodeRcon(command) { + const match = command.match(/^\/sc exp_scenario\.features\.receive_update\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s); + return JSON.parse(match[2]); +} + +t.test("class InstancePlugin", t2 => { + t2.test(".onStart() subscribes and sends every feature to lua", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.onStart(); + + t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first"); + t3.ok(state.sent[1] instanceof messages.FeatureListRequest, "then lists the features"); + const [update] = decodeRcon(state.rcons[0]); + const deathMarkers = features.find(feature => feature.name === "death_markers"); + t3.equal(update.name, "death_markers"); + t3.strictSame([update.values.enabled, update.values.show_map_markers], [false, false], "stored values are sent"); + t3.equal(update.values.collect_corpses, true, "the rest are filled with their defaults"); + t3.equal(Object.keys(update.values).length, deathMarkers.fields.length + 1, "every field and enabled"); + }); + + t2.test(".handleFeatureUpdatedEvent() only sends while the game is up", async t3 => { + const { plugin, state } = await startPlugin(t3); + const event = new messages.FeatureUpdatedEvent([ + new messages.FeatureRecord("afk_kick", true, { afk_minutes: 5 }), + new messages.FeatureRecord("removed", true, {}, 0, true), + ]); + + await plugin.handleFeatureUpdatedEvent(event); + t3.strictSame(state.rcons, [], "nothing is sent before start"); + + await plugin.onStart(); + state.rcons.length = 0; + await plugin.handleFeatureUpdatedEvent(event); + const updates = decodeRcon(state.rcons[0]); + t3.strictSame(updates.map(update => update.name), ["afk_kick"], "deleted features are left out"); + t3.strictSame([updates[0].values.enabled, updates[0].values.afk_minutes], [true, 5]); + + await plugin.onExit(); + state.rcons.length = 0; + await plugin.handleFeatureUpdatedEvent(event); + t3.strictSame(state.rcons, [], "nothing is sent after exit"); + }); + + t2.test(".onControllerConnectionEvent() syncs again after the controller restarts", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.onControllerConnectionEvent("connect"); + t3.strictSame(state.sent, [], "nothing happens before start"); + + await plugin.onStart(); + state.sent.length = 0; + state.rcons.length = 0; + await plugin.onControllerConnectionEvent("resume"); + t3.strictSame(state.sent, [], "a resumed session keeps its subscription"); + + await plugin.onControllerConnectionEvent("connect"); + t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again"); + t3.equal(state.rcons.length, 1, "and sends every feature"); + }); + + t2.test(".luaSendFeatures() picks a long bracket the values do not close", async t3 => { + const { plugin, state } = await startPlugin(t3); + const name = "a]=]b]==]c"; + await plugin.luaSendFeatures([new messages.FeatureRecord("station_auto_name", true, { station_name: name })]); + t3.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json"); + t3.strictSame(decodeRcon(state.rcons[0])[0].values.station_name, name); + }); + + t2.end(); +}); diff --git a/exp_scenario/test/module.test.js b/exp_scenario/test/module.test.js new file mode 100644 index 00000000..0aa3fde7 --- /dev/null +++ b/exp_scenario/test/module.test.js @@ -0,0 +1,8 @@ +import path from "node:path"; +import t from "tap"; +import { reportLuaTests } from "../../test/lua/runner.js"; + +const envFile = path.join(import.meta.dirname, "module", "env.lua"); + +// Each file runs in its own lua state, and each test in a fresh environment +t.test("features.lua", t2 => reportLuaTests(t2, envFile, path.join(import.meta.dirname, "module", "features.lua"))); diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua new file mode 100644 index 00000000..96c41456 --- /dev/null +++ b/exp_scenario/test/module/env.lua @@ -0,0 +1,55 @@ +--[[-- Test environment for module/features.lua +Extends the shared stubs with a fresh copy of the features module and a stub +of exp_util storage which lets tests act out on_init and on_load. + +Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite +returned here becomes `...` in each test file. +]] + +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source:gsub("\\", "/") +local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$")) + +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework + +--- @class ExpScenario.TestEnv : Stubs +--- @field Feature ExpScenario.Features A fresh copy of the features module +--- @field storage table The table registered with storage, as on_init would store it +--- @field on_load fun(tbl: table) Act out on_load with the given storage table +--- @field load_config fun(name: string): ExpScenario.Feature Load module/config/.lua +--- @field logged string[] Messages passed to log +--- @field events table[] Raised events, name and tick filled in + +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 + table_size = function(tbl) + local size = 0 + for _ in pairs(tbl) do size = size + 1 end + return size + end + env.extend_requires{ + ["modules/exp_util/storage"] = { + register = function(tbl, fn) + env.storage = tbl + callback = fn + end, + }, + } + + env.Feature = assert(loadfile(plugin_root .. "/module/features.lua"))() + env.extend_requires{ ["modules/exp_scenario/features"] = env.Feature } + + function env.on_load(tbl) + env.storage = tbl + callback(tbl) + end + + function env.load_config(name) + return assert(loadfile(plugin_root .. "/module/config/" .. name .. ".lua"))() + end + + return env +end) diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua new file mode 100644 index 00000000..37284577 --- /dev/null +++ b/exp_scenario/test/module/features.lua @@ -0,0 +1,152 @@ +local Suite = ... --- @type Suite + +--- The raised on_config_updated events as { path, old value, new value }, sorted by path +local function updates(env) + local rtn = {} + for _, event in ipairs(env.events) do + rtn[#rtn + 1] = { event.path, event.old_value, event.new_value } + end + table.sort(rtn, function(a, b) return a[1] < b[1] end) + return rtn +end + +Suite.test("register() enables features unless the config says otherwise", function(env) + Suite.eq(env.Feature.register("on", { value = 1 }).config.enabled, true, "enabled by default") + Suite.eq(env.Feature.register("off", { enabled = false }):is_enabled(), false, "the config can disable it") + Suite.eq(env.Feature.register("empty").config, { enabled = true }, "config is optional") + Suite.throws(function() env.Feature.register("on", {}) end, "Feature already registered: on", "names are unique") +end) + +Suite.test("register() returns the config as well", function(env) + local feature, config = env.Feature.register("test", { flag = true }) + Suite.eq(config == feature.config, true, "the same table") +end) + +Suite.test("get() finds registered features", function(env) + local feature = env.Feature.register("test") + Suite.eq(env.Feature.get("test") == feature, true, "by name") + Suite.throws(function() env.Feature.get("unknown") end, "Unknown feature: unknown", "unknown names error") +end) + +Suite.test("update_config() sets every value, missing ones to their default", function(env) + local feature, config = env.Feature.register("test", { flag = true, count = 5, text = "a" }) + feature:update_config{ enabled = false, flag = false, count = 7 } + Suite.eq(config, { enabled = false, flag = false, count = 7, text = "a" }, "given values are set") + + feature:update_config{ enabled = true, text = "b" } + Suite.eq(config, { enabled = true, flag = true, count = 5, text = "b" }, "values left out go back to their default") + Suite.eq(env.storage, { test = { enabled = true, text = "b" } }, "the values are kept in storage") +end) + +Suite.test("update_config() raises on_config_updated for each changed value", function(env) + local feature = env.Feature.register("test", { flag = true, names = env.Feature.set{ "a" }, section = { count = 1 } }) + feature:update_config{ enabled = false, flag = true, names = { "a" }, ["section.count"] = 2 } + Suite.eq(env.Feature.events.on_config_updated, assert(env.events[1]).name, "the event is on_config_updated") + Suite.eq(updates(env), { + { "enabled", true, false }, + { "section.count", 1, 2 }, + }, "with the path, old value, and new value, unchanged values raise nothing") +end) + +Suite.test("update_config() raises when a value goes back to its default", function(env) + local feature = env.Feature.register("test", { flag = true }) + feature:update_config{ enabled = true, flag = false } + env.events = {} + feature:update_config{ enabled = true } + Suite.eq(updates(env), { { "flag", false, true } }, "the value reverts") +end) + +Suite.test("update_config() ignores and logs values of the wrong type", function(env) + local function check() return true end + local feature, config = env.Feature.register("test", { flag = true, check = check }) + feature:update_config{ enabled = true, flag = "yes", check = false } + Suite.eq(config, { enabled = true, flag = true, check = check }, "wrong types and functions keep their default") + Suite.eq(#env.logged, 2, "each one is logged") +end) + +Suite.test("receive_update() updates known features", function(env) + local feature = env.Feature.register("test", { flag = true }) + env.Feature.receive_update{ + { name = "test", values = { enabled = true, flag = false } }, + { name = "unknown", values = { enabled = false } }, + } + Suite.eq(feature.config.flag, false, "the known feature changed") + Suite.eq(env.storage.unknown, nil, "unknown features are not stored") +end) + +Suite.test("on_load applies the stored values without raising events", function(env) + local feature, config = env.Feature.register("test", { flag = true }) + env.on_load{ test = { enabled = false, flag = false, gone = 1 } } + Suite.eq(config, { enabled = false, flag = false }, "the saved values apply, unknown ones are skipped") + Suite.empty(env.events, "nothing is raised") + + feature:update_config{ enabled = true } + Suite.eq(env.storage, { test = { enabled = true } }, "later updates replace the stored values") +end) + +Suite.test("guard() skips handlers while the feature is disabled", function(env) + local feature = env.Feature.register("test") + local calls = {} + local lib = feature:guard{ + events = { on_event = function(event) calls[#calls + 1] = event end }, + on_nth_tick = { [60] = function(event) calls[#calls + 1] = event end }, + on_init = function() end, + } + + lib.events.on_event("a") + lib.on_nth_tick[60]("b") + feature:update_config{ enabled = false } + lib.events.on_event("c") + lib.on_nth_tick[60]("d") + Suite.eq(calls, { "a", "b" }, "only called while enabled") +end) + +Suite.test("register() addresses nested values by path", function(env) + local offset = { 1, 2 } + local feature, config = env.Feature.register("test", { section = { flag = true, count = 1 }, offset = offset }) + feature:update_config{ enabled = true, ["section.flag"] = false, offset = { "x" } } + Suite.eq(config.section, { flag = false, count = 1 }, "the nested value changes in place") + Suite.eq(config.offset, offset, "tables which are not sections can not be set") + + feature:update_config{ enabled = true } + Suite.eq(config.section.flag, true, "and goes back to its default") +end) + +Suite.test("list() declares a list of strings", function(env) + local feature, config = env.Feature.register("test", { names = env.Feature.list{ "a", "b" }, empty = env.Feature.list{}, plain = { "x" } }) + Suite.eq(config.names, { "a", "b" }, "the default is the list") + feature:update_config{ enabled = true, names = { "c" }, empty = { "d" }, plain = { "y" } } + Suite.eq(config.names, { "c" }, "a list is replaced") + Suite.eq(config.empty, { "d" }, "an empty list can be set") + Suite.eq(config.plain, { "x" }, "a plain table can not be set") + feature:update_config{ enabled = true, names = { 1 } } + Suite.eq(config.names, { "a", "b" }, "a list with other types is invalid") +end) + +Suite.test("set() declares a set of strings which is sent as a list", function(env) + local feature, config = env.Feature.register("test", { names = env.Feature.set{ "a" }, empty = env.Feature.set{} }) + Suite.eq(config.names, { a = true }, "the default is a set") + Suite.eq(config.empty, {}, "and can be empty") + feature:update_config{ enabled = true, names = { "b", "c" } } + Suite.eq(config.names, { b = true, c = true }, "the list is converted to a set") + env.on_load{ test = { enabled = true, names = { "d" } } } + Suite.eq(config.names, { d = true }, "also when the values are loaded") +end) + +Suite.test("optional() declares a value without a default", function(env) + local feature, config = env.Feature.register("test", { role_id = env.Feature.optional("number") }) + Suite.eq(config.role_id, nil, "nil until set") + feature:update_config{ enabled = true, role_id = 3 } + Suite.eq(config.role_id, 3, "set with the given type") + feature:update_config{ enabled = true } + Suite.eq(config.role_id, nil, "and back to nil") +end) + +Suite.test("config files register their feature", function(env) + local feature = env.load_config("spawn_area") + feature:update_config{ enabled = true, ["turrets.enabled"] = false } + Suite.eq(feature.config.turrets.enabled, false, "spawn_area can be changed") + Suite.eq(feature.config.turrets.ammo_type, "uranium-rounds-magazine", "other values keep their default") +end) + +return Suite.run() diff --git a/exp_scenario/tsconfig.browser.json b/exp_scenario/tsconfig.browser.json index b67577c5..c60f963b 100644 --- a/exp_scenario/tsconfig.browser.json +++ b/exp_scenario/tsconfig.browser.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.browser.json", - "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "permissions.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "features.ts", "permissions.ts", "package.json" ], } diff --git a/exp_scenario/web/components/FeaturesPage.tsx b/exp_scenario/web/components/FeaturesPage.tsx new file mode 100644 index 00000000..1ef64f77 --- /dev/null +++ b/exp_scenario/web/components/FeaturesPage.tsx @@ -0,0 +1,349 @@ +import React, { useContext, useState } from "react"; +import { Button, Checkbox, Col, ConfigProvider, Divider, Input, InputNumber, Row, Select, Space, Spin, Switch } from "antd"; + +import * as lib from "@clusterio/lib"; +import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount } from "@clusterio/web_ui"; + +import { Feature, FeatureField, FeatureValue, features, isDefaultValue, sameValue } from "../../features.js"; +import { FeatureRecord, FeatureUpdateRequest } from "../../messages.js"; +import { useFeatures } from "../index"; + +type FeatureState = { enabled: boolean, values: Record }; +type PendingChanges = Record }>; + +// Same colours as the role and permission group pages +const borderColor = "#424242"; +const modifiedColor = "#2a1912"; + +/** The stored state of a feature, with defaults for values which were not set. */ +function storedState(feature: Feature, record: FeatureRecord | undefined): FeatureState { + return { + enabled: record?.enabled ?? true, + values: { + ...Object.fromEntries(feature.fields.map(field => [field.name, field.default])), + ...record?.values, + }, + }; +} + +/** Group fields by the section in their name, "turrets.enabled" is in the "turrets" section. */ +function groupFields(fields: FeatureField[]) { + const groups = new Map(); + for (const field of fields) { + const section = field.name.includes(".") ? field.name.slice(0, field.name.lastIndexOf(".")) : ""; + if (!groups.has(section)) { + groups.set(section, []); + } + groups.get(section)!.push(field); + } + return [...groups.entries()]; +} + +/** "afk_belts" to "Afk Belts", like 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); +} + +/** Fields matching the search, every field when 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) + )); +} + +/** Renders a setting like the config pages do, registered input components take priority */ +function FieldInput(props: { field: FeatureField, value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) { + const { field, value, disabled, onChange } = props; + const control = useContext(ControlContext); + if (field.type !== "string_list" && field.inputComponent) { + const CustomInput = control.inputComponents.get(field.inputComponent); + if (CustomInput) { + const fieldDefinition: lib.FieldDefinition = { + type: field.type, + title: field.title, + description: field.description, + optional: field.optional, + inputComponent: field.inputComponent, + }; + return } + onChange={onChange} + disabled={disabled} + />; + } + } + + switch (field.type) { + case "number": + return onChange(next ?? (field.optional ? null : field.default))} + />; + case "string": + if (field.enum) { + return onChange(e.target.value)} style={{ width: 300 }} />; + case "string_list": + return