Merge pull request #484 from bbassie/feature/scenario-config

Add a web page for configuring scenario features
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-10-04 20:29:40 +01:00
commit b4ee485b13
49 files changed
+2331 -576

No files matched your search

-14
View File
@@ -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,
}
-15
View File
@@ -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,
}
@@ -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,
}
-6
View File
@@ -1,6 +0,0 @@
--- Settings for lawnmower
-- @config lawnmower
return {
destroy_decoratives = false,
}
-8
View File
@@ -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
}
-30
View File
@@ -1,30 +0,0 @@
return {
inventories = {
{
inventory = defines.inventory.character_ammo,
event = defines.events.on_player_ammo_inventory_changed,
items = {
["atomic-bomb"] = true,
},
},
{
inventory = defines.inventory.character_armor,
event = defines.events.on_player_armor_inventory_changed,
items = {},
},
{
inventory = defines.inventory.character_guns,
event = defines.events.on_player_gun_inventory_changed,
items = {},
},
{
inventory = defines.inventory.character_main,
event = defines.events.on_player_main_inventory_changed,
items = {
["atomic-bomb"] = true,
},
},
},
ignore_permission = "exp_scenario.bypass.nuke_protection", -- @setting ignore_permission The permission that nukeprotect will ignore
ignore_admins = true, -- @setting ignore_admins Ignore admins, true by default. Allows usage outside of the roles module
}
@@ -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
}
-17
View File
@@ -1,17 +0,0 @@
return {
ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter
ignore_permission = "exp_scenario.bypass.entity_protection", --- @setting ignore_permission Players with this permission will be ignored by the protection filter, leave nil to protect against every player
repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_lifetime in order to trigger repeated removal protection
repeat_lifetime = 3600 * 20, --- @setting repeat_lifetime The length of time, in ticks, that protected removals will be remembered for
refresh_rate = 3600 * 5, --- @setting refresh_rate How often the age of protected removals are checked against repeat_lifetime
always_protected_names = { --- @setting always_protected_names Names of entities which are always protected
},
always_protected_types = { --- @setting always_protected_types Types of entities which are always protected
"boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo",
},
always_trigger_repeat_names = { --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection
},
always_trigger_repeat_types = { --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection
"reactor", "fusion-reactor", "rocket-silo",
},
}
@@ -1,11 +0,0 @@
return {
--[[
__icon__
__item_name__
__backer_name__
__direction__
__x__
__y__
]]
station_name = "[L] __icon__",
}
+76
View File
@@ -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<messages.FeatureRecord>;
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;
}
/**
+288
View File
@@ -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> = 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<string, FeatureValue>) {
const feature = features.find(f => f.name === name);
if (!feature) {
throw new Error(`Unknown feature ${name}`);
}
const result: Record<string, FeatureValue> = {};
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<string, FeatureValue>) {
const kept: Record<string, FeatureValue> = {};
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 };
}
+6
View File
@@ -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",
],
};
+85 -3
View File
@@ -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<string, FeatureValue> = { 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();
}
+117
View File
@@ -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<string, FeatureValue> = {},
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<typeof FeatureRecord.jsonSchema> = {
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<typeof this.jsonSchema>) {
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<typeof this.jsonSchema>) {
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<string, FeatureValue>,
) {}
static jsonSchema = Type.Object({
id: Type.String(),
enabled: Type.Boolean(),
values: Type.Record(Type.String(), FeatureValueSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id, json.enabled, json.values);
}
}
@@ -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)
+14 -11
View File
@@ -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,
}
@@ -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<string, true>
--- @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
@@ -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<string, LocalisedString | fun(player: LuaPlayer, is_command: boolean): LocalisedString>
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<string, LocalisedString | fun(player: LuaPlayer, is_command: boolean): LocalisedString>
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,
},
}
})
@@ -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 },
},
}
})
+3 -3
View File
@@ -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"))
+27 -8
View File
@@ -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,
}
@@ -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,
}
},
}
@@ -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,
}
}
@@ -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,
},
}
+24 -21
View File
@@ -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,
},
}
+154 -141
View File
@@ -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,
}
+21
View File
@@ -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,
},
}
@@ -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,
},
+25 -17
View File
@@ -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,
}
+92
View File
@@ -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,
},
}
+79 -48
View File
@@ -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<string, true>
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<uint, table<string, LuaEntity>> Keyed by surface index then entity key
local protected_areas = {} --- @type table<uint, table<string, BoundingBox>> Keyed by surface index then area key
local repeats = {} --- @type table<string, ExpScenario_Protection.Repeat> Keyed by player name
local protected_entities = {} --- @type table<uint, table<string, LuaEntity>> By surface index then entity key
local protected_areas = {} --- @type table<uint, table<string, BoundingBox>> By surface index then area key
local registrations = {} --- @type table<uint64, ExpScenario_Protection.Registration> By on_object_destroyed registration number
local repeats = {} --- @type table<string, ExpScenario_Protection.Repeat> 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)
+22 -24
View File
@@ -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,
}
},
}
@@ -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,
}
},
}
+282
View File
@@ -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<string, ExpScenario.Feature>
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<string, ExpScenario.FeatureField> By path
--- @field values table<string, any> 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<string, table<string, any>>
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<string, true>
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<string, ExpScenario.FeatureField>
--- @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<string, true>
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<string, any> 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<string, any>
--- 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
+6
View File
@@ -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"),
}
+6 -3
View File
@@ -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,
}
},
}
+11
View File
@@ -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,
}
}
+3
View File
@@ -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.
+1
View File
@@ -3,6 +3,7 @@
"load": [
],
"require": [
"globals.lua",
"control.lua"
],
"dependencies": {
+3
View File
@@ -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",
+60 -3
View File
@@ -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();
});
+117
View File
@@ -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();
});
+124
View File
@@ -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();
});
+8
View File
@@ -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")));
+55
View File
@@ -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/<name>.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)
+152
View File
@@ -0,0 +1,152 @@
local Suite = ... --- @type Suite<ExpScenario.TestEnv>
--- 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()
+1 -1
View File
@@ -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" ],
}
@@ -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<string, FeatureValue> };
type PendingChanges = Record<string, { enabled?: boolean, values: Record<string, FeatureValue> }>;
// Same colours as the role and permission group pages
const borderColor = "#424242";
const modifiedColor = "#2a1912";
/** The stored state of a feature, with defaults for values which were not set. */
function storedState(feature: Feature, record: FeatureRecord | undefined): FeatureState {
return {
enabled: record?.enabled ?? true,
values: {
...Object.fromEntries(feature.fields.map(field => [field.name, field.default])),
...record?.values,
},
};
}
/** Group fields by the section in their name, "turrets.enabled" is in the "turrets" section. */
function groupFields(fields: FeatureField[]) {
const groups = new Map<string, FeatureField[]>();
for (const field of fields) {
const section = field.name.includes(".") ? field.name.slice(0, field.name.lastIndexOf(".")) : "";
if (!groups.has(section)) {
groups.set(section, []);
}
groups.get(section)!.push(field);
}
return [...groups.entries()];
}
/** "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 <CustomInput
fieldDefinition={fieldDefinition}
value={value as Exclude<FeatureValue, string[]>}
onChange={onChange}
disabled={disabled}
/>;
}
}
switch (field.type) {
case "number":
return <InputNumber
value={value as number}
min={field.min}
addonAfter={field.unit}
disabled={disabled}
onChange={next => onChange(next ?? (field.optional ? null : field.default))}
/>;
case "string":
if (field.enum) {
return <Select
showSearch
style={{ minWidth: 175 }}
value={value as string}
options={field.enum.map(option => ({ label: option, value: option }))}
allowClear={field.optional}
disabled={disabled}
onChange={next => onChange(next ?? null)}
/>;
}
return <Input value={value as string} disabled={disabled} onChange={e => onChange(e.target.value)} style={{ width: 300 }} />;
case "string_list":
return <Select
mode="tags"
value={value as string[]}
disabled={disabled}
onChange={next => onChange(next)}
open={false}
style={{ width: "100%", maxWidth: 600 }}
/>;
default:
return null;
}
}
/** A setting, laid out like a permission on the role page */
function FieldItem(props: {
field: FeatureField, value: FeatureValue, stored: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void,
}) {
const { field, value, stored, disabled, onChange } = props;
const modified = !sameValue(value, stored);
return <div style={{
marginBottom: 8,
padding: "4px 6px",
background: modified ? modifiedColor : undefined,
borderRadius: 4,
}}>
{field.type === "boolean"
? <Checkbox checked={value as boolean} disabled={disabled} onChange={e => onChange(e.target.checked)}>
{field.title}
</Checkbox>
: <Space wrap>
<span>{field.title}</span>
<FieldInput field={field} value={value} disabled={disabled} onChange={onChange} />
</Space>}
<div style={{ marginLeft: field.type === "boolean" ? 24 : 0, color: "#888", fontSize: 12 }}>
{field.description}
</div>
</div>;
}
function FeatureSection(props: {
feature: Feature,
fields: FeatureField[],
state: FeatureState,
stored: FeatureState,
canEdit: boolean,
onEnabled: (enabled: boolean) => void,
onValue: (name: string, value: FeatureValue) => void,
onReset: () => void,
}) {
const { feature, fields, state, stored, canEdit } = props;
const atDefaults = feature.fields.every(field => isDefaultValue(field, state.values[field.name]));
return <div id={`feature-${feature.name}`} style={{ border: `1px solid ${borderColor}`, padding: 12, borderRadius: 6, scrollMarginTop: 80 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{
padding: "4px 6px",
borderRadius: 4,
background: state.enabled !== stored.enabled ? modifiedColor : undefined,
}}>
<Switch size="default" checked={state.enabled} disabled={!canEdit} onChange={props.onEnabled} />
</div>
<div style={{ flexGrow: 1 }}>
<strong>{feature.title}</strong>
<div style={{ color: "#888", fontSize: 12 }}>{feature.description}</div>
</div>
{canEdit && feature.fields.length > 0 && <Button size="small" disabled={atDefaults} onClick={props.onReset}>
Reset to defaults
</Button>}
</div>
{fields.length > 0 && <>
<Divider style={{ margin: "12px 0" }} />
<div style={{ opacity: state.enabled ? 1 : 0.6 }}>
{groupFields(fields).map(([section, sectionFields]) => <div key={section}>
{section && <div style={{ fontWeight: 600, margin: "8px 6px 4px" }}>{formatSectionTitle(section)}</div>}
{sectionFields.map(field => <FieldItem
key={field.name}
field={field}
value={state.values[field.name]}
stored={stored.values[field.name]}
disabled={!canEdit}
onChange={value => props.onValue(field.name, value)}
/>)}
</div>)}
</div>
</>}
</div>;
}
function FeatureNav(props: { feature: Feature, enabled: boolean }) {
return <div
role="button"
onClick={() => document.getElementById(`feature-${props.feature.name}`)?.scrollIntoView({ behavior: "smooth" })}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "4px 8px", cursor: "pointer", borderRadius: 4 }}
>
<span style={{
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
background: props.enabled ? "#52c41a" : "#595959",
}} />
{props.feature.title}
</div>;
}
/** The pending changes with one more, dropped again when it matches what is stored */
function withChange(pending: PendingChanges, stored: FeatureState, name: string, update: (entry: PendingChanges[string]) => void) {
const entry = { enabled: pending[name]?.enabled, values: { ...pending[name]?.values } };
update(entry);
if (entry.enabled === stored.enabled) {
delete entry.enabled;
}
for (const [key, value] of Object.entries(entry.values)) {
if (sameValue(value, stored.values[key])) {
delete entry.values[key];
}
}
const next = { ...pending };
if (entry.enabled === undefined && !Object.keys(entry.values).length) {
delete next[name];
} else {
next[name] = entry;
}
return next;
}
function UnsavedChangesBar(props: { onRevert: () => void, onApply: () => void }) {
return <div style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: modifiedColor,
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 12,
zIndex: 1000,
}}>
<span style={{ color: "#FFF" }}>You have unsaved changes</span>
<Space>
<Button onClick={props.onRevert}>Revert</Button>
<Button type="primary" onClick={props.onApply}>Apply</Button>
</Space>
</div>;
}
/** Enable, disable, and configure the features listed in features.ts */
export default function FeaturesPage() {
const control = useContext(ControlContext);
const account = useAccount();
const [records, synced] = useFeatures();
const [pending, setPending] = useState<PendingChanges>({});
const [search, setSearch] = useState("");
const canEdit = Boolean(account.hasPermission("exp_scenario.config.edit"));
// Before the first update every feature would look enabled with its defaults
if (!synced) {
return <PageLayout nav={[{ name: "Scenario Features" }]}>
<PageHeader title="Scenario Features" />
<Spin size="large" />
</PageLayout>;
}
const stored = Object.fromEntries(features.map(feature => [feature.name, storedState(feature, records.get(feature.name))]));
const current = (name: string): FeatureState => ({
enabled: pending[name]?.enabled ?? stored[name].enabled,
values: { ...stored[name].values, ...pending[name]?.values },
});
function change(name: string, update: (entry: PendingChanges[string]) => void) {
setPending(prev => withChange(prev, stored[name], name, update));
}
function applyChanges() {
for (const name of Object.keys(pending)) {
const state = current(name);
control.send(new FeatureUpdateRequest(name, state.enabled, state.values))
.then(() => setPending(prev => {
const next = { ...prev };
delete next[name];
return next;
}))
.catch(notifyErrorHandler(`Error saving ${features.find(f => f.name === name)?.title ?? name}`));
}
}
const query = search.trim().toLowerCase();
const shown = features
.map(feature => ({ feature, fields: searchFields(feature, query) }))
.filter(({ feature, fields }) => !query || fields.length || matches(feature.title, query));
return <PageLayout nav={[{ name: "Scenario Features" }]}>
<PageHeader title="Scenario Features" />
{Object.keys(pending).length > 0 && <UnsavedChangesBar onRevert={() => setPending({})} onApply={applyChanges} />}
<Input.Search placeholder="Search features and settings" allowClear
onChange={e => setSearch(e.target.value)} style={{ marginBottom: 16 }} />
<Row gutter={16} wrap={false}>
<Col flex="200px">
{/* Clear of the fixed page header */}
<div style={{ position: "sticky", top: 80 }}>
{shown.map(({ feature }) => <FeatureNav
key={feature.name}
feature={feature}
enabled={current(feature.name).enabled}
/>)}
</div>
</Col>
<Col flex="auto" style={{ minWidth: 0 }}>
{/* Same input size as the config pages */}
<ConfigProvider componentSize="small">
<Space direction="vertical" style={{ width: "100%" }}>
{shown.map(({ feature, fields }) => <FeatureSection
key={feature.name}
feature={feature}
fields={fields}
state={current(feature.name)}
stored={stored[feature.name]}
canEdit={canEdit}
onEnabled={enabled => change(feature.name, entry => { entry.enabled = enabled; })}
onValue={(key, value) => change(feature.name, entry => { entry.values[key] = value; })}
onReset={() => change(feature.name, entry => {
for (const field of feature.fields) {
entry.values[field.name] = field.default;
}
})}
/>)}
</Space>
</ConfigProvider>
</Col>
</Row>
</PageLayout>;
}
+23
View File
@@ -1,9 +1,32 @@
import React, { useCallback, useSyncExternalStore } from "react";
import { WebPluginContext } from "@clusterio/web_ui";
import * as lib from "@clusterio/lib";
import * as messages from "../messages.js";
import FeaturesPage from "./components/FeaturesPage";
import Seed from "./components/Seed";
let features: lib.MapSubscriber<messages.FeatureUpdatedEvent> | undefined;
export function useFeatures() {
const subscribe = useCallback((cb: () => void) => features!.subscribe(cb), []);
return useSyncExternalStore(subscribe, () => features!.getSnapshot());
}
export default function registerPlugin(context: WebPluginContext) {
context.control.hooks.extensionComponents.attach("exp_scenario", () => ({
RolesPage: Seed,
}));
context.control.hooks.pages.attach("exp_scenario", () => [
{
path: "/scenario_features",
sidebarName: "Scenario Features",
permission: "exp_scenario.config.view",
content: <FeaturesPage />,
},
]);
features = new lib.MapSubscriber(messages.FeatureUpdatedEvent, context.control);
}