mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-12 07:54:52 +09:00
Merge pull request #484 from bbassie/feature/scenario-config
Add a web page for configuring scenario features
This commit is contained in:
49 files changed
+2331
-576
No files matched your search
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
--- Settings for lawnmower
|
||||
-- @config lawnmower
|
||||
|
||||
return {
|
||||
destroy_decoratives = false,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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__",
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-9
@@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
+35
-35
@@ -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 },
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"load": [
|
||||
],
|
||||
"require": [
|
||||
"globals.lua",
|
||||
"control.lua"
|
||||
],
|
||||
"dependencies": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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")));
|
||||
@@ -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)
|
||||
@@ -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,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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user