diff --git a/exp_auto_restart/controller.ts b/exp_auto_restart/controller.ts index 130dd2b0..5695c293 100644 --- a/exp_auto_restart/controller.ts +++ b/exp_auto_restart/controller.ts @@ -16,10 +16,6 @@ export interface PendingRestart { } export class AutoRestart { - controller: Controller; - logger: lib.Logger; - name: string; - /** When each host, and the cluster as a whole, last became empty of players. Absent while players are online. */ idleSince = new Map(); /** Hosts that were told to restart and are waiting for their instances to come back */ @@ -31,32 +27,36 @@ export class AutoRestart { controllerRestarting = false; private checkInterval?: ReturnType; - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + ) {} - /** Attach to the controller hooks and start checking for restarts. */ - start() { - const hooks = this.controller.hooks; - hooks.shutdown.attach(this.name, () => this.stop()); - hooks.playerEvent.attach(this.name, (instance, event) => this.onPlayerEvent(instance, event)); - hooks.instanceStatusChanged.attach(this.name, (instance, prev) => this.onInstanceStatusChanged(instance, prev)); - hooks.hostConnectionEvent.attach(this.name, (connection, event) => this.onHostConnectionEvent(connection, event)); + /** Create the plugin, attach to the controller hooks and start checking for restarts. */ + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const plugin = new AutoRestart(controller, context.logger, context.plugin.name); - if (this.controller.config.get("controller.system_metrics_interval") <= 0) { - this.logger.warn( + const hooks = controller.hooks; + hooks.shutdown.attach(plugin.name, () => plugin.stop()); + hooks.playerEvent.attach(plugin.name, (instance, event) => plugin.onPlayerEvent(instance, event)); + hooks.instanceStatusChanged.attach(plugin.name, (instance, prev) => plugin.onInstanceStatusChanged(instance, prev)); + hooks.hostConnectionEvent.attach(plugin.name, (connection, event) => plugin.onHostConnectionEvent(connection, event)); + + if (controller.config.get("controller.system_metrics_interval") <= 0) { + plugin.logger.warn( "controller.system_metrics_interval is 0, so the controller never learns which hosts need a restart" ); } - this.refreshIdle(); - this.checkInterval = setInterval(() => { - this.check().catch((err: any) => { - this.logger.error(`Unexpected error checking for restarts:\n${err.stack ?? err.message}`); + plugin.refreshIdle(); + plugin.checkInterval = setInterval(() => { + plugin.check().catch((err: any) => { + plugin.logger.error(`Unexpected error checking for restarts:\n${err.stack ?? err.message}`); }); }, CHECK_INTERVAL_MS); - this.checkInterval.unref(); + plugin.checkInterval.unref(); + return plugin; } stop() { @@ -291,5 +291,5 @@ export class AutoRestart { } export default async function (context: ControllerPluginContext) { - new AutoRestart(context).start(); + await AutoRestart.fromContext(context); } diff --git a/exp_auto_restart/test/controller.test.js b/exp_auto_restart/test/controller.test.js index e7461f53..05ec45e9 100644 --- a/exp_auto_restart/test/controller.test.js +++ b/exp_auto_restart/test/controller.test.js @@ -35,8 +35,7 @@ async function startPlugin(t2, { config = {}, canRestart = false } = {}) { sent.push({ address, message }); }; - const plugin = new AutoRestart({ controller, logger, plugin: pluginDeclaration, metrics: undefined }); - plugin.start(); + const plugin = await AutoRestart.fromContext({ controller, logger, plugin: pluginDeclaration, metrics: undefined }); t2.teardown(() => plugin.stop()); return { plugin, controller, sent, logs }; } @@ -344,7 +343,7 @@ t.test("class AutoRestart", t2 => { t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("--can-restart"))); }); - t2.test(".start() warns when system metrics are disabled", async t3 => { + t2.test(".fromContext() warns when system metrics are disabled", async t3 => { const { logs } = await startPlugin(t3, { config: { "controller.system_metrics_interval": 0 } }); t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("system_metrics_interval"))); }); diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index 89f6bd1e..63c17413 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -6,81 +6,82 @@ import * as path from "node:path"; const loaded = new WeakMap(); export class ControllerPlugin { - controller: Controller; - logger: lib.Logger; - name: string; - groups!: lib.SubscribableDatastore; - roleMappings!: lib.SubscribableDatastore; - manualAssignments!: lib.SubscribableDatastore; - resolvedAssignments!: lib.SubscribableDatastore; + resolvedAssignments = new lib.SubscribableDatastore(); /** The plugin loaded on a controller, for the exp_scenario seed. */ static get(controller: Controller) { return loaded.get(controller); } - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + public groups: lib.SubscribableDatastore, + public roleMappings: lib.SubscribableDatastore, + public manualAssignments: lib.SubscribableDatastore, + ) {} - async init() { - loaded.set(this.controller, this); - const databaseDirectory = this.controller.config.get("controller.database_directory"); + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const databaseDirectory = controller.config.get("controller.database_directory"); - this.groups = new lib.SubscribableDatastore( + const groups = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_groups", "groups.json"), messages.GroupRecord.fromJSON.bind(messages.GroupRecord), ).bootstrap() ); - this.roleMappings = new lib.SubscribableDatastore( + const roleMappings = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_groups", "role_mappings.json"), messages.RoleMappingRecord.fromJSON.bind(messages.RoleMappingRecord), ).bootstrap() ); - this.manualAssignments = new lib.SubscribableDatastore( + const manualAssignments = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_groups", "assignments.json"), messages.AssignmentRecord.fromJSON.bind(messages.AssignmentRecord), ).bootstrap() ); - this.resolvedAssignments = new lib.SubscribableDatastore(); + const plugin = new ControllerPlugin( + controller, context.logger, context.plugin.name, groups, roleMappings, manualAssignments, + ); + loaded.set(controller, plugin); - this.controller.subscriptions.handle(messages.GroupUpdatedEvent, this.handleGroupSubscription.bind(this)); - this.controller.subscriptions.handle(messages.RoleMappingUpdatedEvent, this.handleRoleMappingSubscription.bind(this)); - this.controller.subscriptions.handle(messages.ManualAssignmentUpdatedEvent, this.handleManualAssignmentSubscription.bind(this)); - this.controller.subscriptions.handle(messages.ResolvedAssignmentUpdatedEvent, this.handleResolvedAssignmentSubscription.bind(this)); + controller.subscriptions.handle(messages.GroupUpdatedEvent, plugin.handleGroupSubscription.bind(plugin)); + controller.subscriptions.handle(messages.RoleMappingUpdatedEvent, plugin.handleRoleMappingSubscription.bind(plugin)); + controller.subscriptions.handle(messages.ManualAssignmentUpdatedEvent, plugin.handleManualAssignmentSubscription.bind(plugin)); + controller.subscriptions.handle(messages.ResolvedAssignmentUpdatedEvent, plugin.handleResolvedAssignmentSubscription.bind(plugin)); - this.groups.on("update", this.groupsUpdated.bind(this)); - this.roleMappings.on("update", this.roleMappingsUpdated.bind(this)); - this.manualAssignments.on("update", this.manualAssignmentsUpdated.bind(this)); - this.resolvedAssignments.on("update", this.resolvedAssignmentsUpdated.bind(this)); + groups.on("update", plugin.groupsUpdated.bind(plugin)); + roleMappings.on("update", plugin.roleMappingsUpdated.bind(plugin)); + manualAssignments.on("update", plugin.manualAssignmentsUpdated.bind(plugin)); + plugin.resolvedAssignments.on("update", plugin.resolvedAssignmentsUpdated.bind(plugin)); - this.controller.handle(messages.GroupCreateRequest, this.handleGroupCreateRequest.bind(this)); - this.controller.handle(messages.GroupUpdateRequest, this.handleGroupUpdateRequest.bind(this)); - this.controller.handle(messages.GroupDeleteRequest, this.handleGroupDeleteRequest.bind(this)); - this.controller.handle(messages.GroupGetRequest, this.handleGroupGetRequest.bind(this)); - this.controller.handle(messages.GroupListRequest, this.handleGroupListRequest.bind(this)); + controller.handle(messages.GroupCreateRequest, plugin.handleGroupCreateRequest.bind(plugin)); + controller.handle(messages.GroupUpdateRequest, plugin.handleGroupUpdateRequest.bind(plugin)); + controller.handle(messages.GroupDeleteRequest, plugin.handleGroupDeleteRequest.bind(plugin)); + controller.handle(messages.GroupGetRequest, plugin.handleGroupGetRequest.bind(plugin)); + controller.handle(messages.GroupListRequest, plugin.handleGroupListRequest.bind(plugin)); - this.controller.handle(messages.AssignmentCreateRequest, this.handleAssignmentCreateRequest.bind(this)); - this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); - this.controller.handle(messages.AssignmentDeleteRequest, this.handleAssignmentDeleteRequest.bind(this)); - this.controller.handle(messages.AssignmentGetRequest, this.handleAssignmentGetRequest.bind(this)); - this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); + controller.handle(messages.AssignmentCreateRequest, plugin.handleAssignmentCreateRequest.bind(plugin)); + controller.handle(messages.AssignmentUpdateRequest, plugin.handleAssignmentUpdateRequest.bind(plugin)); + controller.handle(messages.AssignmentDeleteRequest, plugin.handleAssignmentDeleteRequest.bind(plugin)); + controller.handle(messages.AssignmentGetRequest, plugin.handleAssignmentGetRequest.bind(plugin)); + controller.handle(messages.AssignmentListRequest, plugin.handleAssignmentListRequest.bind(plugin)); - this.controller.handle(messages.RoleMappingCreateRequest, this.handleRoleMappingCreateRequest.bind(this)); - this.controller.handle(messages.RoleMappingUpdateRequest, this.handleRoleMappingUpdateRequest.bind(this)); - this.controller.handle(messages.RoleMappingDeleteRequest, this.handleRoleMappingDeleteRequest.bind(this)); - this.controller.handle(messages.RoleMappingGetRequest, this.handleRoleMappingGetRequest.bind(this)); - this.controller.handle(messages.RoleMappingListRequest, this.handleRoleMappingListRequest.bind(this)); + controller.handle(messages.RoleMappingCreateRequest, plugin.handleRoleMappingCreateRequest.bind(plugin)); + controller.handle(messages.RoleMappingUpdateRequest, plugin.handleRoleMappingUpdateRequest.bind(plugin)); + controller.handle(messages.RoleMappingDeleteRequest, plugin.handleRoleMappingDeleteRequest.bind(plugin)); + controller.handle(messages.RoleMappingGetRequest, plugin.handleRoleMappingGetRequest.bind(plugin)); + controller.handle(messages.RoleMappingListRequest, plugin.handleRoleMappingListRequest.bind(plugin)); - this.controller.hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); + controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + return plugin; } async onShutdown() { @@ -453,5 +454,5 @@ export class ControllerPlugin { } export default async function (context: ControllerPluginContext) { - await new ControllerPlugin(context).init(); + await ControllerPlugin.fromContext(context); } diff --git a/exp_groups/instance.ts b/exp_groups/instance.ts index 594f025d..88e97769 100644 --- a/exp_groups/instance.ts +++ b/exp_groups/instance.ts @@ -18,9 +18,6 @@ export type IpcPlayerAssignments = { }; export class InstancePlugin { - instance: Instance; - logger: lib.Logger; - name: string; // Once only, don't send permissions for these groups // This is used for groups created on this instance that only need the controller generated id skipSendingPermissions = new Set(); @@ -29,23 +26,27 @@ export class InstancePlugin { // Track known online players so that we only apply assignment updates for them onlinePlayers = new Set(); - constructor(context: InstancePluginContext) { - this.instance = context.instance; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} - async init() { - this.instance.handle(messages.GroupUpdatedEvent, this.handleGroupUpdatedEvent.bind(this)); - this.instance.handle(messages.ResolvedAssignmentUpdatedEvent, this.handleResolvedAssignmentUpdatedEvent.bind(this)); - this.instance.server.handle(`exp_group:group_updated`, this.handleGroupUpdatedIPC.bind(this)) - this.instance.server.handle(`exp_group:group_deleted`, this.handleGroupDeletedIPC.bind(this)) - this.instance.server.handle(`exp_group:player_assignments`, this.handlePlayerAssignmentsIPC.bind(this)) + static async fromContext(context: InstancePluginContext) { + const instance = context.instance; + const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - const hooks = this.instance.hooks; - hooks.instanceConfigFieldChanged.attach(this.name, this.onInstanceConfigFieldChanged.bind(this)); - hooks.start.attach(this.name, this.onStart.bind(this)); - hooks.playerEvent.attach(this.name, this.onPlayerEvent.bind(this)); + instance.handle(messages.GroupUpdatedEvent, plugin.handleGroupUpdatedEvent.bind(plugin)); + instance.handle(messages.ResolvedAssignmentUpdatedEvent, plugin.handleResolvedAssignmentUpdatedEvent.bind(plugin)); + instance.server.handle(`exp_group:group_updated`, plugin.handleGroupUpdatedIPC.bind(plugin)) + instance.server.handle(`exp_group:group_deleted`, plugin.handleGroupDeletedIPC.bind(plugin)) + instance.server.handle(`exp_group:player_assignments`, plugin.handlePlayerAssignmentsIPC.bind(plugin)) + + const hooks = instance.hooks; + hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin)); + hooks.start.attach(plugin.name, plugin.onStart.bind(plugin)); + hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin)); + return plugin; } async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { @@ -185,5 +186,5 @@ export class InstancePlugin { } export default async function (context: InstancePluginContext) { - await new InstancePlugin(context).init(); + await InstancePlugin.fromContext(context); } diff --git a/exp_legacy/module/config/afk_kick.lua b/exp_legacy/module/config/afk_kick.lua deleted file mode 100644 index 3e52552a..00000000 --- a/exp_legacy/module/config/afk_kick.lua +++ /dev/null @@ -1,14 +0,0 @@ -local Roles = require("modules/exp_roles") - -return { - admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time - trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time - afk_time = 3600 * 10, --- @setting afk_time The time in ticks that must pass for a player to be considered afk - kick_time = 3600 * 30, --- @setting kick_time The time in ticks that must pass without any active players for all players to be kicked - trust_time = 3600 * 60 * 10, --- @setting trust_time The time in ticks that a player must be online for to count as trusted - update_time = 3600 * 30, --- @setting update_time How often in ticks the script checks for active players - custom_active_check = function(player) - local veteran = Roles.get_role_by_name("Veteran") - return veteran ~= nil and not Roles.get_player_highest_role(player):is_lower_than(veteran) - end, -} diff --git a/exp_legacy/module/config/bonus.lua b/exp_legacy/module/config/bonus.lua deleted file mode 100644 index 5c1a3c22..00000000 --- a/exp_legacy/module/config/bonus.lua +++ /dev/null @@ -1,105 +0,0 @@ ---- Lists all bonuses which can be used, name followed by min max --- @config Bonuses - -return { - --[[ - Base point is equal to the amount of standard value in each parameter. - - CMMS CRS CCS CISB CHB CRDB PBR - STD 20 60 24 10 12 8 40 - = 174 - - MAX 40 120 48 20 24 16 80 - = 348 - ]] - points = { - base = 174, - increase_percentage_per_role_level = 0.03, - role_name = "Member", - }, - periodic_bonus_rate = 300, - player_bonus = { - { - name = "character_mining_speed_modifier", - scale = 1, - cost = 10, - max_value = 4, - value_step = 0.5, - is_percentage = true, - }, - { - name = "character_running_speed_modifier", - scale = 1, - cost = 60, - max_value = 2, - value_step = 0.25, - is_percentage = true, - }, - { - name = "character_crafting_speed_modifier", - scale = 1, - cost = 4, - max_value = 12, - value_step = 1, - is_percentage = true, - }, - { - name = "character_inventory_slots_bonus", - scale = 10, - cost = 2, - max_value = 100, - value_step = 10, - }, - { - name = "character_health_bonus", - scale = 50, - cost = 4, - max_value = 300, - value_step = 50, - }, - { - name = "character_reach_distance_bonus", - scale = 1, - cost = 1, - max_value = 16, - value_step = 2, - combined_bonus = { - "character_resource_reach_distance_bonus", - "character_build_distance_bonus", - }, - }, - { - name = "personal_battery_recharge", - scale = 4, - cost = 40, - max_value = 8, - value_step = 1, - is_special = true, - }, - }, - force_bonus = { - ["worker_robots_battery_modifier"] = { - max_value = 1, - value_step = 1, - scale = 1, - cost = 1, - is_percentage = false, - }, - ["worker_robots_storage_bonus"] = { - max_value = 1, - value_step = 1, - scale = 1, - cost = 1, - is_percentage = false, - }, - ["following_robots_lifetime_modifier"] = { - max_value = 1, - value_step = 1, - scale = 1, - cost = 1, - is_percentage = false, - }, - }, - surface_bonus = { - }, -} diff --git a/exp_legacy/module/config/compilatron.lua b/exp_legacy/module/config/compilatron.lua deleted file mode 100644 index 53a6cc3e..00000000 --- a/exp_legacy/module/config/compilatron.lua +++ /dev/null @@ -1,26 +0,0 @@ ---- Config file for the compliatrons including where they spawn and what messages they show --- @config Compilatron -local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail - -return { - message_cycle = 60 * 15, --- @setting message_cycle 15 seconds default, how often (in ticks) the messages will cycle - locations = { - ["Spawn"] = { - spawn_position = { x = 0, y = 0 }, - spawn_surface = "nauvis", - entity_name = "small-biter", - messages = { - { "info.website", config_server_detail["website"] }, - { "info.read-readme" }, - { "info.discord", config_server_detail["discord"] }, - { "info.softmod" }, - { "info.redmew" }, - { "info.custom-commands" }, - { "info.status", config_server_detail["status"] }, - { "info.lhd" }, - { "info.github", config_server_detail["github"] }, - { "info.patreon", config_server_detail["patreon"] }, - }, - } - }, -} diff --git a/exp_legacy/module/config/death_logger.lua b/exp_legacy/module/config/death_logger.lua deleted file mode 100644 index ca90677a..00000000 --- a/exp_legacy/module/config/death_logger.lua +++ /dev/null @@ -1,15 +0,0 @@ ---- This config controls what happens when a player dies mostly about map markers and item collection; --- allow_teleport_to_body_command and allow_collect_bodies_command can be over ridden if command_auth_runtime_disable is present; --- if not present then the commands will not be loaded into the game --- @config Death-Logger - -return { - collect_corpses = true, --- @setting collect_corpses enables items being returned to the spawn point in chests upon corpse expiring - show_map_markers = true, --- @setting show_map_markers shows markers on the map where bodies are - clean_map_markers = false, - include_time_of_death = true, --- @setting include_time_of_death weather to include the time of death on the map marker - map_icon = nil, --- @setting map_icon the icon that the map marker shows; nil means no icon; format as a SingleID - show_light_at_corpse = true, --- @setting show_light_at_corpse if a light should be rendered at the corpse - show_line_to_corpse = true, --- @setting show_line_to_corpse if a line should be rendered from you to your corpse - period_check_map_tags = 60 * 60 * 5, -} diff --git a/exp_legacy/module/config/deconlog.lua b/exp_legacy/module/config/deconlog.lua deleted file mode 100644 index c55dba24..00000000 --- a/exp_legacy/module/config/deconlog.lua +++ /dev/null @@ -1,11 +0,0 @@ ---- This config controls whether actions such as deconning by players without sufficient permissions is logged or not --- @config Deconlog - -return { - decon_area = true, --- @setting decon_area whether to log when an area is being deconstructed - built_entity = true, --- @setting built_entity whether to log when an entity is built - mined_entity = true, --- @setting mined_entity whether to log when an entity is mined - fired_rocket = true, --- @setting fired_nuke whether to log when a rocket is fired - fired_explosive_rocket = true, --- @setting fired_nuke whether to log when a explosive rocket is fired - fired_nuke = true, --- @setting fired_nuke whether to log when a nuke is fired -} diff --git a/exp_legacy/module/config/discord_alerts.lua b/exp_legacy/module/config/discord_alerts.lua deleted file mode 100644 index bc51d78c..00000000 --- a/exp_legacy/module/config/discord_alerts.lua +++ /dev/null @@ -1,23 +0,0 @@ ---- Config file used to enable and disable different push messages for discord --- @config Discord-Alerts - -return { - show_playtime = true, - entity_protection = true, - player_bans = true, - player_mutes = true, - player_kicks = true, - player_promotes = false, - player_jail = true, - ["config"] = true, - ["purge"] = true, - ["c"] = true, - ["command"] = true, - ["silent-command"] = true, - ["measured-command"] = true, - ["banlist"] = true, - ["permissions"] = true, - ["editor"] = true, - ["cheat"] = true, - ["open"] = false, -} diff --git a/exp_legacy/module/config/graftorio.lua b/exp_legacy/module/config/graftorio.lua deleted file mode 100644 index 9848a1ca..00000000 --- a/exp_legacy/module/config/graftorio.lua +++ /dev/null @@ -1,7 +0,0 @@ -return { - modules = { - ["forcestats"] = true, - ["logistorage"] = false, - ["other"] = true, - }, -} diff --git a/exp_legacy/module/config/gui/rockets.lua b/exp_legacy/module/config/gui/rockets.lua deleted file mode 100644 index c1c7e53f..00000000 --- a/exp_legacy/module/config/gui/rockets.lua +++ /dev/null @@ -1,47 +0,0 @@ ---- This file controls what will show in each section of the rocket info gui --- @config Rockets - -return { - stats = { --- @setting stats The data that will show in the stats section - show_stats = true, --- @setting show_stats false will hide this section all together - show_first_rocket = true, --- @setting show_first_rocket false will not show when the first rocket was launched - show_last_rocket = true, --- @setting show_last_rocket false will not show when the last rocket was launched - show_fastest_rocket = true, --- @setting show_fastest_rocket false will not show the time taken for the fastest rocket - show_total_rockets = true, --- @setting show_total_rockets false will not show the total number of rockets launched - show_game_avg = true, --- @setting show_game_avg false will hide the avg across the entire map time - rolling_avg = { --- @setting rolling_avg each number will be one statistic; 5 means the avg time taken for the last 5 rockets - 5, 10, 25, - }, - }, - milestones = { --- @setting milestones each number will be one statistic; 5 means the time that the 5th rocket was launched - show_milestones = true, --- @setting show_milestones false will hide this section all together - 1, - 2, - 5, - 10, - 20, - 50, - 100, - 200, - 500, - 1000, - 1500, - 2000, - 2500, - 3000, - 3500, - 4000, - 4500, - 5000, - }, - progress = { --- @setting progress The data and buttons in the build progress section - show_progress = true, --- @setting show_progress false will hide this section altogether - allow_zoom_to_map = true, --- @setting allow_zoom_to_map false will disable the zoom to map feature - allow_remote_launch = true, --- @setting allow_remote_launch false removes the remote launch button for all players - remote_launch_admins_only = false, --- @setting remote_launch_admins_only true will remove the remote launch button for all non (game) admins - remote_launch_role_permission = "exp_scenario.gui.rocket_info.remote_launch", --- @setting remote_launch_role_permission value used by custom permission system to allow or disallow the button - allow_toggle_active = true, --- @setting allow_toggle_active false removes the remote toggle auto launch button for all players - toggle_active_admins_only = false, --- @setting toggle_active_admins_only true will remove the toggle auto launch button for all non (game) admins - toggle_active_role_permission = "exp_scenario.gui.rocket_info.toggle_active", --- @setting toggle_active_role_permission value used by custom permission system to allow or disallow the button - }, -} diff --git a/exp_legacy/module/config/gui/science.lua b/exp_legacy/module/config/gui/science.lua deleted file mode 100644 index b8128bbe..00000000 --- a/exp_legacy/module/config/gui/science.lua +++ /dev/null @@ -1,21 +0,0 @@ ---- Config file for the science info gui --- @config Science - -return { - -- list of all science packs to be shown in the gui - show_eta = true, --- @setting show_eta when true the eta for research completion will be shown - color_cutoff = 0.8, --- @setting color_cutoff the amount that production can fall before the text changes color - color_flux = 0.1, --- @setting color_flux the amount of fluctuation allowed in production before the icon changes color - "automation-science-pack", - "logistic-science-pack", - "military-science-pack", - "chemical-science-pack", - "production-science-pack", - "utility-science-pack", - "space-science-pack", - "metallurgic-science-pack", - "agricultural-science-pack", - "electromagnetic-science-pack", - "cryogenic-science-pack", - "promethium-science-pack", -} diff --git a/exp_legacy/module/config/gui/tasks.lua b/exp_legacy/module/config/gui/tasks.lua deleted file mode 100644 index 969b6ab3..00000000 --- a/exp_legacy/module/config/gui/tasks.lua +++ /dev/null @@ -1,13 +0,0 @@ ---- Config file for the tasks gui --- @config Tasks - -return { - -- Adding tasks - allow_add_task = "all", --- @setting allow_add_task dictates who is allowed to add new tasks; values: all, admin, exp_roles, none - exp_roles_allow_add_task = "exp_scenario.gui.task_list.add", --- @setting exp_roles_allow_add_task if exp_roles is used then this is the required permission - - -- Editing tasks - allow_edit_task = "exp_roles", --- @setting allow_edit_task dictates who is allowed to edit existing tasks; values: all, admin, exp_roles, none - exp_roles_allow_edit_task = "exp_scenario.gui.task_list.edit", --- @setting exp_roles_allow_edit_task if exp_roles is used then this is the required permission - user_can_edit_own_tasks = true, --- @settings if true then the user who made the task can edit it regardless of the allow_edit_task setting -} diff --git a/exp_legacy/module/config/gui/warps.lua b/exp_legacy/module/config/gui/warps.lua deleted file mode 100644 index fa6c142b..00000000 --- a/exp_legacy/module/config/gui/warps.lua +++ /dev/null @@ -1,52 +0,0 @@ ---- This file contains all the different settings for the warp system and gui --- @config Warps - -return { - -- General config - update_smoothing = 10, --- @setting update_smoothing the amount of smoothing applied to updates to the cooldown timer, higher is better, max is 60 - minimum_distance = 100, --- @setting minimum_distance the minimum distance that is allowed between warps on the same force - default_icon = { type = "item", name = "discharge-defense-equipment" }, --- @setting default_icon the default icon that will be used for warps - - -- Warp cooldowns - bypass_warp_cooldown = "exp_roles", --- @setting bypass_warp_cooldown dictates who the warp cooldown is applied to; values: all, admin, exp_roles, none - exp_roles_bypass_warp_cooldown = "exp_scenario.gui.warp_list.bypass_cooldown", --- @setting exp_roles_bypass_warp_cooldown if exp_roles is used then this is the required permission - cooldown_duration = 60, --- @setting cooldown_duration the duration of the warp cooldown in seconds - - -- Warp proximity - bypass_warp_proximity = "exp_roles", --- @setting bypass_warp_proximity dictates who the warp proximity is applied to; values: all, admin, exp_roles, none - exp_roles_bypass_warp_proximity = "exp_scenario.gui.warp_list.bypass_proximity", --- @setting exp_roles_bypass_warp_proximity if exp_roles is used then this is the required permission - standard_proximity_radius = 4, --- @setting standard_proximity_radius the minimum distance a player is allowed to be to a warp in order to use it - spawn_proximity_radius = 20, --- @setting spawn_proximity_radius the minimum distance a player is allowed to be from they spawn point to use warps - - -- Adding warps - allow_add_warp = "exp_roles", --- @setting allow_add_warp dictates who is allowed to add warps; values: all, admin, exp_roles, none - exp_roles_allow_add_warp = "exp_scenario.gui.warp_list.add", --- @setting exp_roles_allow_add_warp if exp_roles is used then this is the required permission - - -- Editing warps - allow_edit_warp = "exp_roles", --- @setting allow_edit_warp dictates who is allowed to edit warps; values: all, admin, exp_roles, none - exp_roles_allow_edit_warp = "exp_scenario.gui.warp_list.edit", --- @setting exp_roles_allow_edit_warp if exp_roles is used then this is the required permission - user_can_edit_own_warps = false, --- @settings user_can_edit_own_warps if true then the user who made the warp can edit it regardless of the allow_edit_warp setting - - -- Warp area generation - entities = { --- @setting entities The entities which are created for warp areas - { "small-lamp", -4, -2 }, { "small-lamp", -2, -4 }, { "medium-electric-pole", -3, -3 }, -- Top left corner - { "small-lamp", 3, -2 }, { "small-lamp", 1, -4 }, { "medium-electric-pole", 2, -3 }, -- Top right corner - { "small-lamp", 3, 1 }, { "small-lamp", 1, 3 }, { "medium-electric-pole", 2, 2 }, -- Bottom right corner - { "small-lamp", -4, 1 }, { "small-lamp", -2, 3 }, { "medium-electric-pole", -3, 2 }, -- Bottom left corner - }, - tiles = { --- @setting tiles The tiles which are created for warp areas - { "black-refined-concrete", -4, -2 }, { "black-refined-concrete", -4, -1 }, { "black-refined-concrete", -4, 0 }, { "black-refined-concrete", -4, 1 }, - { "black-refined-concrete", -3, -3 }, { "purple-refined-concrete", -3, -2 }, { "purple-refined-concrete", -3, -1 }, { "purple-refined-concrete", -3, 0 }, - { "purple-refined-concrete", -3, 1 }, { "black-refined-concrete", -3, 2 }, { "black-refined-concrete", -2, -4 }, { "purple-refined-concrete", -2, -3 }, - { "purple-refined-concrete", -2, -2 }, { "purple-refined-concrete", -2, -1 }, { "purple-refined-concrete", -2, 0 }, { "purple-refined-concrete", -2, 1 }, - { "purple-refined-concrete", -2, 2 }, { "black-refined-concrete", -2, 3 }, { "black-refined-concrete", -1, -4 }, { "purple-refined-concrete", -1, -3 }, - { "purple-refined-concrete", -1, -2 }, { "purple-refined-concrete", -1, -1 }, { "purple-refined-concrete", -1, 0 }, { "purple-refined-concrete", -1, 1 }, - { "purple-refined-concrete", -1, 2 }, { "black-refined-concrete", -1, 3 }, { "black-refined-concrete", 0, -4 }, { "purple-refined-concrete", 0, -3 }, - { "purple-refined-concrete", 0, -2 }, { "purple-refined-concrete", 0, -1 }, { "purple-refined-concrete", 0, 0 }, { "purple-refined-concrete", 0, 1 }, - { "purple-refined-concrete", 0, 2 }, { "black-refined-concrete", 0, 3 }, { "black-refined-concrete", 1, -4 }, { "purple-refined-concrete", 1, -3 }, - { "purple-refined-concrete", 1, -2 }, { "purple-refined-concrete", 1, -1 }, { "purple-refined-concrete", 1, 0 }, { "purple-refined-concrete", 1, 1 }, - { "purple-refined-concrete", 1, 2 }, { "black-refined-concrete", 1, 3 }, { "black-refined-concrete", 2, -3 }, { "purple-refined-concrete", 2, -2 }, - { "purple-refined-concrete", 2, -1 }, { "purple-refined-concrete", 2, 0 }, { "purple-refined-concrete", 2, 1 }, { "black-refined-concrete", 2, 2 }, - { "black-refined-concrete", 3, -2 }, { "black-refined-concrete", 3, -1 }, { "black-refined-concrete", 3, 0 }, { "black-refined-concrete", 3, 1 }, - }, -} diff --git a/exp_legacy/module/config/inventory_clear.lua b/exp_legacy/module/config/inventory_clear.lua deleted file mode 100644 index f0f24419..00000000 --- a/exp_legacy/module/config/inventory_clear.lua +++ /dev/null @@ -1,9 +0,0 @@ ---- Config to control when players items are removed, this is a list of event names that will trigger inventory clear --- @config inventory_clear - -local events = defines.events -return { - events.on_player_banned, - events.on_player_kicked, - -- events.on_player_left_game -} diff --git a/exp_legacy/module/config/lawnmower.lua b/exp_legacy/module/config/lawnmower.lua deleted file mode 100644 index f31559c8..00000000 --- a/exp_legacy/module/config/lawnmower.lua +++ /dev/null @@ -1,6 +0,0 @@ ---- Settings for lawnmower --- @config lawnmower - -return { - destroy_decoratives = false, -} diff --git a/exp_legacy/module/config/logging.lua b/exp_legacy/module/config/logging.lua deleted file mode 100644 index eb66cfbf..00000000 --- a/exp_legacy/module/config/logging.lua +++ /dev/null @@ -1,30 +0,0 @@ ---- Settings for logging --- @config logging - -return { - file_name = "log/logging.log", - rocket_launch_display = { - [1] = true, - [2] = true, - [5] = true, - [10] = true, - [20] = true, - [50] = true, - [100] = true, - [200] = true, - }, - rocket_launch_display_rate = 500, - disconnect_reason = { - [defines.disconnect_reason.quit] = "left the game", - [defines.disconnect_reason.dropped] = "was dropped from the game", - [defines.disconnect_reason.reconnect] = "is reconnecting", - [defines.disconnect_reason.wrong_input] = "was having a wrong input", - [defines.disconnect_reason.desync_limit_reached] = "had desync limit reached", - [defines.disconnect_reason.cannot_keep_up] = "cannot keep up", - [defines.disconnect_reason.afk] = "was afk", - [defines.disconnect_reason.kicked] = "was kicked", - [defines.disconnect_reason.kicked_and_deleted] = "was kicked and deleted", - [defines.disconnect_reason.banned] = "was banned", - [defines.disconnect_reason.switching_servers] = "is switching servers", - }, -} diff --git a/exp_legacy/module/config/miner.lua b/exp_legacy/module/config/miner.lua deleted file mode 100644 index f6fcf84c..00000000 --- a/exp_legacy/module/config/miner.lua +++ /dev/null @@ -1,8 +0,0 @@ ---- Settings for miner --- @config miner - -return { - fluid = true, --- @setting fluid When true, checks for for fluid pipes when removing miners - chest = true, --- @setting chest When true, checks for for chest when removing miners - beacon = true, --- @setting beacon When true, checks for for beacon when removing miners -} diff --git a/exp_legacy/module/config/module.lua b/exp_legacy/module/config/module.lua deleted file mode 100644 index 1e073453..00000000 --- a/exp_legacy/module/config/module.lua +++ /dev/null @@ -1,86 +0,0 @@ -return { - module_slots_per_row = 4, - module_slot_max = 8, - copy_paste_module = true, - copy_paste_rotation = false, - machines = { - }, - machine_sets = { - ["base"] = { - ["electric-mining-drill"] = { - ["module"] = "efficiency-module", - ["prod"] = true, - }, - ["pumpjack"] = { - ["module"] = "efficiency-module", - ["prod"] = true, - }, - ["assembling-machine-2"] = { - ["module"] = "productivity-module", - ["prod"] = true, - }, - ["assembling-machine-3"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["electric-furnace"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["beacon"] = { - ["module"] = "speed-module-3", - ["prod"] = false, - }, - ["oil-refinery"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["chemical-plant"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["centrifuge"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["lab"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["rocket-silo"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - } - }, - ["space-age"] = { - ["big-mining-drill"] = { - ["module"] = "efficiency-module", - ["prod"] = true, - }, - ["biochamber"] = { - ["module"] = "efficiency-module", - ["prod"] = true, - }, - ["electromagnetic-plant"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["cryogenic-plant"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["biolab"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["foundry"] = { - ["module"] = "productivity-module-3", - ["prod"] = true, - }, - ["recycler"] = { - ["module"] = "quality-module-3", - ["prod"] = true, - } - } - } -} diff --git a/exp_legacy/module/config/nukeprotect.lua b/exp_legacy/module/config/nukeprotect.lua deleted file mode 100644 index 197ddb00..00000000 --- a/exp_legacy/module/config/nukeprotect.lua +++ /dev/null @@ -1,30 +0,0 @@ -return { - inventories = { - { - inventory = defines.inventory.character_ammo, - event = defines.events.on_player_ammo_inventory_changed, - items = { - ["atomic-bomb"] = true, - }, - }, - { - inventory = defines.inventory.character_armor, - event = defines.events.on_player_armor_inventory_changed, - items = {}, - }, - { - inventory = defines.inventory.character_guns, - event = defines.events.on_player_gun_inventory_changed, - items = {}, - }, - { - inventory = defines.inventory.character_main, - event = defines.events.on_player_main_inventory_changed, - items = { - ["atomic-bomb"] = true, - }, - }, - }, - ignore_permission = "exp_scenario.bypass.nuke_protection", -- @setting ignore_permission The permission that nukeprotect will ignore - ignore_admins = true, -- @setting ignore_admins Ignore admins, true by default. Allows usage outside of the roles module -} diff --git a/exp_legacy/module/config/pollution_grading.lua b/exp_legacy/module/config/pollution_grading.lua deleted file mode 100644 index 07e5f3f2..00000000 --- a/exp_legacy/module/config/pollution_grading.lua +++ /dev/null @@ -1,9 +0,0 @@ ---- This controls how pollution is viewed on the map --- @config Pollution-Grading - -return { - reference_point = { x = 0, y = 0 }, --- @setting reference_point where pollution is read from - max_scalar = 0.5, --- @setting max_scalar the scale between true max and max - min_scalar = 0.17, --- @setting min_scalar the scale between the lowest max and min - update_delay = 15, --- @setting update_delay time in minutes between view updates -} diff --git a/exp_legacy/module/config/popup_messages.lua b/exp_legacy/module/config/popup_messages.lua deleted file mode 100644 index 3a098d0e..00000000 --- a/exp_legacy/module/config/popup_messages.lua +++ /dev/null @@ -1,10 +0,0 @@ ---- A combination of config settings for different popup values like chat and damage --- @config Popup-Messages - -return { - show_player_messages = true, --- @setting show_player_messages weather a message in chat will make a popup above them - show_player_mentions = true, --- @setting show_player_mentions weather a mentioned player will have a popup when mentioned in chat - show_player_damage = true, --- @setting show_player_damage weather to show damage done by players - show_player_health = true, --- @setting show_player_health weather to show player health when attacked - damage_location_variance = 0.8, --- @setting damage_location_variance how close to the eade of an entity the popups will appear -} diff --git a/exp_legacy/module/config/protection.lua b/exp_legacy/module/config/protection.lua deleted file mode 100644 index d645d04b..00000000 --- a/exp_legacy/module/config/protection.lua +++ /dev/null @@ -1,17 +0,0 @@ -return { - ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter - ignore_permission = "exp_scenario.bypass.entity_protection", --- @setting ignore_permission Players with this permission will be ignored by the protection filter, leave nil to protect against every player - repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_lifetime in order to trigger repeated removal protection - repeat_lifetime = 3600 * 20, --- @setting repeat_lifetime The length of time, in ticks, that protected removals will be remembered for - refresh_rate = 3600 * 5, --- @setting refresh_rate How often the age of protected removals are checked against repeat_lifetime - always_protected_names = { --- @setting always_protected_names Names of entities which are always protected - }, - always_protected_types = { --- @setting always_protected_types Types of entities which are always protected - "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", - }, - always_trigger_repeat_names = { --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection - }, - always_trigger_repeat_types = { --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection - "reactor", "fusion-reactor", "rocket-silo", - }, -} diff --git a/exp_legacy/module/config/repair.lua b/exp_legacy/module/config/repair.lua deleted file mode 100644 index e2fd69ad..00000000 --- a/exp_legacy/module/config/repair.lua +++ /dev/null @@ -1,8 +0,0 @@ ---- Config file for the repair command --- @config Repair - -return { - allow_blueprint_repair = false, --- @setting allow_blueprint_repair when true will allow blueprints (things not destroyed by biters) to be build instantly using the repair command - allow_ghost_revive = true, --- @setting allow_ghost_revive when true will allow ghosts (things destroyed by biters) to be build instantly using the repair command - allow_heal_entities = true, --- @setting allow_heal_entities when true will heal entities to full health that are within range -} diff --git a/exp_legacy/module/config/scorched_earth.lua b/exp_legacy/module/config/scorched_earth.lua deleted file mode 100644 index ee33cd44..00000000 --- a/exp_legacy/module/config/scorched_earth.lua +++ /dev/null @@ -1,116 +0,0 @@ ---- This file controls the placement/degrading of tiles as players build and walk --- @config Scorched-Earth - -return { - weakness_value = 70, --- @setting weakness_value lower value will make tiles more likely to degrade - strengths = { --- @setting strengths this decides how "strong" a tile is, bigger number means less likely to degrade - -- debug: /interface require('modules.addons.worn-paths')(player.name,true) - -- note: tiles are effected by the tiles around them, so player paths will not degrade as fast when made wider - -- note: values are relative to the tile with the highest value, recommended to keep highest tile as a "nice" number - -- note: tiles not in list will never degrade under any conditions (which is why some are omitted such as water) - ["refined-concrete"] = 100, - ["refined-hazard-concrete-left"] = 100, - ["refined-hazard-concrete-right"] = 100, - ["concrete"] = 90, - ["hazard-concrete-left"] = 90, - ["hazard-concrete-right"] = 90, - ["stone-path"] = 80, - ["red-desert-0"] = 80, - ["dry-dirt"] = 50, - -- grass four (main grass tiles) - ["grass-1"] = 50, - ["grass-2"] = 40, - ["grass-3"] = 30, - ["grass-4"] = 25, - -- red three (main red tiles) - ["red-desert-1"] = 40, - ["red-desert-2"] = 30, - ["red-desert-3"] = 25, - -- sand three (main sand tiles) - ["sand-1"] = 40, - ["sand-2"] = 30, - ["sand-3"] = 25, - -- dirt 3 (main dirt tiles) - ["dirt-1"] = 40, - ["dirt-2"] = 30, - ["dirt-3"] = 25, - -- last three/four (all sets of three merge here) - ["dirt-4"] = 25, - ["dirt-5"] = 30, - ["dirt-6"] = 40, - -- ["dirt-7"]=0, -- last tile, nothing to degrade to - -- land fill chain - -- ["landfill"]=50, - -- ["water-shallow"]=90, - -- ["water-mud"]=0, -- last tile, nothing to degrade to - }, - degrade_order = { --- @setting degrade_order when a tile degrades it will turn into the next tile given here - ["refined-concrete"] = "concrete", - ["refined-hazard-concrete-left"] = "hazard-concrete-left", - ["refined-hazard-concrete-right"] = "hazard-concrete-right", - ["concrete"] = "stone-path", - ["hazard-concrete-left"] = "stone-path", - ["hazard-concrete-right"] = "stone-path", - ["stone-path"] = "dry-dirt", - ["red-desert-0"] = "dry-dirt", - ["dry-dirt"] = "dirt-4", - -- grass four (main grass tiles) - ["grass-1"] = "grass-2", - ["grass-2"] = "grass-3", - ["grass-3"] = "grass-4", - ["grass-4"] = "dirt-4", - -- red three (main red tiles) - ["red-desert-1"] = "red-desert-2", - ["red-desert-2"] = "red-desert-3", - ["red-desert-3"] = "dirt-4", - -- sand three (main sand tiles) - ["sand-1"] = "sand-2", - ["sand-2"] = "sand-3", - ["sand-3"] = "dirt-4", - -- dirt 3 (main dirt tiles) - ["dirt-1"] = "dirt-2", - ["dirt-2"] = "dirt-3", - ["dirt-3"] = "dirt-4", - -- last three/four (all sets of three merge here) - ["dirt-4"] = "dirt-5", - ["dirt-5"] = "dirt-6", - ["dirt-6"] = "dirt-7", - -- ["dirt-7"]=0, -- last tile, nothing to degrade to - -- land fill chain - -- ["landfill"]='grass-2', -- 'water-shallow' - -- ["water-shallow"]='water-mud', - -- ["water-mud"]=0, -- last tile, nothing to degrade to - }, - entities = { --- @setting entities entities in this list will degrade the tiles under them when they are placed - ["stone-furnace"] = true, - ["steel-furnace"] = true, - ["electric-furnace"] = true, - ["assembling-machine-1"] = true, - ["assembling-machine-2"] = true, - ["assembling-machine-3"] = true, - ["beacon"] = true, - ["centrifuge"] = true, - ["chemical-plant"] = true, - ["oil-refinery"] = true, - ["storage-tank"] = true, - ["nuclear-reactor"] = true, - ["steam-engine"] = true, - ["steam-turbine"] = true, - ["boiler"] = true, - ["heat-exchanger"] = true, - ["stone-wall"] = true, - ["gate"] = true, - ["gun-turret"] = true, - ["laser-turret"] = true, - ["flamethrower-turret"] = true, - ["radar"] = true, - ["lab"] = true, - ["big-electric-pole"] = true, - ["substation"] = true, - ["rocket-silo"] = true, - ["pumpjack"] = true, - ["electric-mining-drill"] = true, - ["roboport"] = true, - ["accumulator"] = true, - }, -} diff --git a/exp_legacy/module/config/station_auto_name.lua b/exp_legacy/module/config/station_auto_name.lua deleted file mode 100644 index 60f93124..00000000 --- a/exp_legacy/module/config/station_auto_name.lua +++ /dev/null @@ -1,11 +0,0 @@ -return { - --[[ - __icon__ - __item_name__ - __backer_name__ - __direction__ - __x__ - __y__ - ]] - station_name = "[L] __icon__", -} diff --git a/exp_legacy/module/config/vlayer.lua b/exp_legacy/module/config/vlayer.lua deleted file mode 100644 index c2e0761a..00000000 --- a/exp_legacy/module/config/vlayer.lua +++ /dev/null @@ -1,180 +0,0 @@ ---- Settings for vlayer including the allowed items, the update frequency, and some cheats --- @config Vlayer - -return { - update_tick_storage = 60, --- @setting update_tick_storage The number of ticks between each update of the storage interfaces - update_tick_energy = 10, --- @setting update_tick_energy The number of ticks between each update of the energy and circuit interfaces - update_tick_gui = 60, --- @setting update_tick_gui The number of ticks between each update of the gui - - unlimited_capacity = false, --- @setting unlimited_capacity When true the vlayer has an unlimited energy capacity, accumulators are not required - unlimited_surface_area = false, --- @setting unlimited_surface_area When true the vlayer has an unlimited surface area, landfill is not required - modded_auto_downgrade = false, --- @setting modded_auto_downgrade When true modded items will be converted into their base game equivalent, original items can not be recovered - power_on_space = false, --- @setting power_on_space When true allow on spaceship - power_on_space_research = { --- @setting power_on_space_research the research level needed to use power_on_space - name = "research-productivity", - level = 10 - }, - - mimic_surface = "nauvis", --- @setting mimic_surface Surface name/index the vlayer will copy its settings from, use nil to use the settings below - surface = { --- @setting surface When mimic_surface is nil these settings will be used instead, see LuaSurface for details - always_day = false, - solar_power_multiplier = 1, - min_brightness = 0.15, - ticks_per_day = 25200, - daytime = 0, - dusk = 0.25, - evening = 0.45, - morning = 0.55, - dawn = 0.75, - }, - - interface_limit = { --- @setting interface_limit Sets the limit for the number of vlayer interfaces that can be created - energy = 1, -- >1 allows for disconnected power networks to receive power - circuit = 20, -- No caveats - storage_input = 20, -- No caveats - storage_output = 1, -- >0 allows for item teleportation (allowed_items only) - }, - - --- @class Vlayer.ItemProperties - --- @field starting_value number - --- @field required_area number - --- @field production number? - --- @field discharge number? - --- @field capacity number? - --- @field surface_area number? - --- @field fuel_value number? - --- @field power boolean? - --- @field modded boolean? Set when the item is registered from a modded equivalent - --- @field base_game_equivalent string? - --- @field multiplier number? - - --- @type table - allowed_items = { --- @setting allowed_items List of all items allowed in vlayer storage and their properties - --[[ - Allowed properties: - starting_value : The amount of the item placed into the vlayer on game start, ignores area requirements - required_area : When greater than 0 the items properties are not applied unless their is sufficient surplus surface area - production : The energy production of the item in MW, used for solar panels - discharge : The energy discharge of the item in MW, used for accumulators - capacity : The energy capacity of the item in MJ, used for accumulators - surface_area : The surface area provided by the item, used for landfill - ]] - ["solar-panel"] = { - starting_value = 0, - required_area = 9, - production = 0.06, -- MW - }, - ["accumulator"] = { - starting_value = 0, - required_area = 4, - discharge = 0.3, -- MW - capacity = 5, -- MJ - }, - ["landfill"] = { - starting_value = 0, - required_area = 0, - surface_area = 20, -- Tiles - }, - ["wood"] = { - starting_value = 0, - required_area = 0, - surface_area = 0, - fuel_value = 2, -- MJ - power = true, -- turn all wood to power to reduce trash - }, - ["coal"] = { - starting_value = 0, - required_area = 0, - surface_area = 0, - fuel_value = 4, -- MJ - power = false, -- turn all coal to power to reduce trash - }, - ["solid-fuel"] = { - starting_value = 0, - required_area = 0, - surface_area = 0, - fuel_value = 12, -- MJ - power = false, -- turn all solid fuel to power to reduce trash - }, - ["rocket-fuel"] = { - starting_value = 0, - required_area = 0, - surface_area = 0, - fuel_value = 100, -- MJ - power = false, -- turn all rocket fuel to power to reduce trash - } - }, - - modded_items = { --- @setting modded_items List of all modded items allowed in vlayer storage and their base game equivalent - ["solar-panel-2"] = { - starting_value = 0, - base_game_equivalent = "solar-panel", - multiplier = 4, - }, - ["solar-panel-3"] = { - starting_value = 0, - base_game_equivalent = "solar-panel", - multiplier = 16, - }, - ["solar-panel-4"] = { - starting_value = 0, - base_game_equivalent = "solar-panel", - multiplier = 64, - }, - ["solar-panel-5"] = { - starting_value = 0, - base_game_equivalent = "solar-panel", - multiplier = 256, - }, - ["solar-panel-6"] = { - starting_value = 0, - base_game_equivalent = "solar-panel", - multiplier = 1024, - }, - ["solar-panel-7"] = { - starting_value = 0, - base_game_equivalent = "solar-panel", - multiplier = 4096, - }, - ["solar-panel-8"] = { - starting_value = 0, - base_game_equivalent = "solar-panel", - multiplier = 16384, - }, - ["accumulator-2"] = { - starting_value = 0, - base_game_equivalent = "accumulator", - multiplier = 4, - }, - ["accumulator-3"] = { - starting_value = 0, - base_game_equivalent = "accumulator", - multiplier = 16, - }, - ["accumulator-4"] = { - starting_value = 0, - base_game_equivalent = "accumulator", - multiplier = 64, - }, - ["accumulator-5"] = { - starting_value = 0, - base_game_equivalent = "accumulator", - multiplier = 256, - }, - ["accumulator-6"] = { - starting_value = 0, - base_game_equivalent = "accumulator", - multiplier = 1024, - }, - ["accumulator-7"] = { - starting_value = 0, - base_game_equivalent = "accumulator", - multiplier = 4096, - }, - ["accumulator-8"] = { - starting_value = 0, - base_game_equivalent = "accumulator", - multiplier = 16384, - }, - } -} diff --git a/exp_reports/controller.ts b/exp_reports/controller.ts index 53c5adb3..f8b6ca59 100644 --- a/exp_reports/controller.ts +++ b/exp_reports/controller.ts @@ -4,36 +4,36 @@ import * as messages from "./messages.js"; import * as path from "node:path"; export class ControllerPlugin { - controller: Controller; - logger: lib.Logger; - name: string; - reports!: lib.SubscribableDatastore; + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + public reports: lib.SubscribableDatastore, + ) {} - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; - this.name = context.plugin.name; - } + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const databaseDirectory = controller.config.get("controller.database_directory"); - async init() { - const databaseDirectory = this.controller.config.get("controller.database_directory"); - - this.reports = new lib.SubscribableDatastore( + const reports = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_reports", "reports.json"), messages.ReportRecord.fromJSON.bind(messages.ReportRecord), ).bootstrap() ); - this.controller.subscriptions.handle(messages.ReportUpdatedEvent, this.handleReportSubscription.bind(this)); - this.reports.on("update", this.reportsUpdated.bind(this)); + const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, reports); - this.controller.handle(messages.ReportListRequest, this.handleReportListRequest.bind(this)); - this.controller.handle(messages.ReportGetRequest, this.handleReportGetRequest.bind(this)); - this.controller.handle(messages.ReportCreateRequest, this.handleReportCreateRequest.bind(this)); - this.controller.handle(messages.ReportDeleteRequest, this.handleReportDeleteRequest.bind(this)); + controller.subscriptions.handle(messages.ReportUpdatedEvent, plugin.handleReportSubscription.bind(plugin)); + reports.on("update", plugin.reportsUpdated.bind(plugin)); - this.controller.hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); + controller.handle(messages.ReportListRequest, plugin.handleReportListRequest.bind(plugin)); + controller.handle(messages.ReportGetRequest, plugin.handleReportGetRequest.bind(plugin)); + controller.handle(messages.ReportCreateRequest, plugin.handleReportCreateRequest.bind(plugin)); + controller.handle(messages.ReportDeleteRequest, plugin.handleReportDeleteRequest.bind(plugin)); + + controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + return plugin; } async onShutdown() { @@ -154,5 +154,5 @@ export class ControllerPlugin { } export default async function (context: ControllerPluginContext) { - await new ControllerPlugin(context).init(); + await ControllerPlugin.fromContext(context); } diff --git a/exp_reports/instance.ts b/exp_reports/instance.ts index e5ae3a43..71c90c2b 100644 --- a/exp_reports/instance.ts +++ b/exp_reports/instance.ts @@ -30,20 +30,20 @@ export type IpcReportDelete = { * stopped by then, in which case the answer is dropped. */ export class InstancePlugin { - instance: Instance; - logger: lib.Logger; - name: string; + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} - constructor(context: InstancePluginContext) { - this.instance = context.instance; - this.logger = context.logger; - this.name = context.plugin.name; - } + static async fromContext(context: InstancePluginContext) { + const instance = context.instance; + const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - async init() { - this.instance.server.handle("exp_reports:create", this.handleCreateIPC.bind(this)); - this.instance.server.handle("exp_reports:list", this.handleListIPC.bind(this)); - this.instance.server.handle("exp_reports:delete", this.handleDeleteIPC.bind(this)); + instance.server.handle("exp_reports:create", plugin.handleCreateIPC.bind(plugin)); + instance.server.handle("exp_reports:list", plugin.handleListIPC.bind(plugin)); + instance.server.handle("exp_reports:delete", plugin.handleDeleteIPC.bind(plugin)); + return plugin; } async handleCreateIPC(event: IpcReportCreate) { @@ -106,5 +106,5 @@ export class InstancePlugin { } export default async function (context: InstancePluginContext) { - await new InstancePlugin(context).init(); + await InstancePlugin.fromContext(context); } diff --git a/exp_reports/test/controller.test.js b/exp_reports/test/controller.test.js index b2dec669..7581cb42 100644 --- a/exp_reports/test/controller.test.js +++ b/exp_reports/test/controller.test.js @@ -46,10 +46,9 @@ async function startPlugin(t2, { config = {} } = {}) { broadcast(event); }; - const plugin = new ControllerPlugin({ plugin: { name: "exp_reports" }, controller, logger }); - plugin.logger = { ...logger, warn: message => state.warnings.push(message) }; + const pluginLogger = { ...logger, warn: message => state.warnings.push(message) }; + const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_reports" }, controller, logger: pluginLogger }); plugin.postWebhook = async (url, body) => { state.posts.push({ url, body }); }; - await plugin.init(); return { plugin, controller, state }; } diff --git a/exp_reports/test/instance.test.js b/exp_reports/test/instance.test.js index e579dcec..4cfcafa5 100644 --- a/exp_reports/test/instance.test.js +++ b/exp_reports/test/instance.test.js @@ -63,8 +63,7 @@ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2, instance.notifyStatus("running"); state.sent.length = 0; - const plugin = new InstancePlugin({ plugin: { name: "exp_reports" }, instance, host: {}, logger }); - await plugin.init(); + const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_reports" }, instance, host: {}, logger }); return { plugin, instance, state }; } diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index 2299de23..f2ed29f3 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -6,59 +6,59 @@ import * as path from "node:path"; const loaded = new WeakMap(); export class ControllerPlugin { - controller: Controller; - logger: lib.Logger; - name: string; - roleMeta!: lib.SubscribableDatastore; - /** The plugin loaded on a controller, for the exp_scenario seed. */ static get(controller: Controller) { return loaded.get(controller); } - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + public roleMeta: lib.SubscribableDatastore, + ) {} - async init() { - loaded.set(this.controller, this); - const databaseDirectory = this.controller.config.get("controller.database_directory"); + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const databaseDirectory = controller.config.get("controller.database_directory"); - this.roleMeta = new lib.SubscribableDatastore( + const roleMeta = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_roles", "role_meta.json"), messages.RoleMetaRecord.fromJSON.bind(messages.RoleMetaRecord), ).bootstrap() ); + const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, roleMeta); + loaded.set(controller, plugin); + // The datastore can be out of step with the roles, either because the // plugin was installed after they were created or because it was // uninstalled while they were deleted - this.ensureRoleMeta(); - this.sweepRoleMeta(); - this.applyAutoAssign(); + plugin.ensureRoleMeta(); + plugin.sweepRoleMeta(); + plugin.applyAutoAssign(); - this.controller.subscriptions.handle(messages.RoleUpdatedEvent, this.handleRoleSubscription.bind(this)); - this.controller.subscriptions.handle( - messages.AssignmentUpdatedEvent, this.handleAssignmentSubscription.bind(this) + controller.subscriptions.handle(messages.RoleUpdatedEvent, plugin.handleRoleSubscription.bind(plugin)); + controller.subscriptions.handle( + messages.AssignmentUpdatedEvent, plugin.handleAssignmentSubscription.bind(plugin) ); - this.roleMeta.on("update", this.roleMetaUpdated.bind(this)); - this.controller.roles.on("update", this.rolesUpdated.bind(this)); - this.controller.users.records.on("update", this.usersUpdated.bind(this)); + roleMeta.on("update", plugin.roleMetaUpdated.bind(plugin)); + controller.roles.on("update", plugin.rolesUpdated.bind(plugin)); + controller.users.records.on("update", plugin.usersUpdated.bind(plugin)); - this.controller.handle(messages.RoleListRequest, this.handleRoleListRequest.bind(this)); - this.controller.handle(messages.RoleMetaUpdateRequest, this.handleRoleMetaUpdateRequest.bind(this)); + controller.handle(messages.RoleListRequest, plugin.handleRoleListRequest.bind(plugin)); + controller.handle(messages.RoleMetaUpdateRequest, plugin.handleRoleMetaUpdateRequest.bind(plugin)); - this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); - this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); + controller.handle(messages.AssignmentListRequest, plugin.handleAssignmentListRequest.bind(plugin)); + controller.handle(messages.AssignmentUpdateRequest, plugin.handleAssignmentUpdateRequest.bind(plugin)); - const hooks = this.controller.hooks; - hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); - hooks.controllerConfigFieldChanged.attach(this.name, this.onControllerConfigFieldChanged.bind(this)); - hooks.playerEvent.attach(this.name, this.onPlayerEvent.bind(this)); + const hooks = controller.hooks; + hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + hooks.controllerConfigFieldChanged.attach(plugin.name, plugin.onControllerConfigFieldChanged.bind(plugin)); + hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin)); + return plugin; } async onShutdown() { @@ -335,5 +335,5 @@ export class ControllerPlugin { } export default async function (context: ControllerPluginContext) { - await new ControllerPlugin(context).init(); + await ControllerPlugin.fromContext(context); } diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts index 488613d6..8cb87e22 100644 --- a/exp_roles/instance.ts +++ b/exp_roles/instance.ts @@ -10,24 +10,24 @@ export type IpcAssignmentUpdate = { }; export class InstancePlugin { - instance: Instance; - logger: lib.Logger; - name: string; + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} - constructor(context: InstancePluginContext) { - this.instance = context.instance; - this.logger = context.logger; - this.name = context.plugin.name; - } + static async fromContext(context: InstancePluginContext) { + const instance = context.instance; + const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - async init() { - this.instance.handle(messages.RoleUpdatedEvent, this.handleRoleUpdatedEvent.bind(this)); - this.instance.handle(messages.AssignmentUpdatedEvent, this.handleAssignmentUpdatedEvent.bind(this)); - this.instance.server.handle("exp_roles:assignment_update", this.handleAssignmentUpdateIPC.bind(this)); + instance.handle(messages.RoleUpdatedEvent, plugin.handleRoleUpdatedEvent.bind(plugin)); + instance.handle(messages.AssignmentUpdatedEvent, plugin.handleAssignmentUpdatedEvent.bind(plugin)); + instance.server.handle("exp_roles:assignment_update", plugin.handleAssignmentUpdateIPC.bind(plugin)); - const hooks = this.instance.hooks; - hooks.instanceConfigFieldChanged.attach(this.name, this.onInstanceConfigFieldChanged.bind(this)); - hooks.start.attach(this.name, this.onStart.bind(this)); + const hooks = instance.hooks; + hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin)); + hooks.start.attach(plugin.name, plugin.onStart.bind(plugin)); + return plugin; } get syncMode() { @@ -122,5 +122,5 @@ export class InstancePlugin { } export default async function (context: InstancePluginContext) { - await new InstancePlugin(context).init(); + await InstancePlugin.fromContext(context); } diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 1231d093..652dca56 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -44,8 +44,7 @@ async function startPlugin(t2, { roles = [] } = {}) { permissionsUpdated(user); }; - const plugin = new ControllerPlugin({ plugin: { name: "exp_roles" }, controller, logger }); - await plugin.init(); + const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_roles" }, controller, logger }); return { plugin, controller, state }; } @@ -62,7 +61,7 @@ t.test("entrypoint", async t2 => { }); t.test("class ControllerPlugin", t2 => { - t2.test(".init() and .sweepRoleMeta() manage the role properties", async t3 => { + t2.test(".fromContext() and .sweepRoleMeta() manage the role properties", async t3 => { const { plugin } = await startPlugin(t3, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); t3.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first"); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js index 302e8528..5921adc7 100644 --- a/exp_roles/test/instance.test.js +++ b/exp_roles/test/instance.test.js @@ -69,9 +69,8 @@ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles return undefined; }; - const plugin = new InstancePlugin({ plugin: { name: "exp_roles" }, instance, host: {}, logger }); - plugin.logger = { ...logger, warn: message => state.warnings.push(message) }; - await plugin.init(); + const pluginLogger = { ...logger, warn: message => state.warnings.push(message) }; + const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_roles" }, instance, host: {}, logger: pluginLogger }); return { plugin, state }; } diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 2914f98c..59557a5b 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -1,23 +1,98 @@ 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; + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + public features: lib.SubscribableDatastore, + ) {} - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const databaseDirectory = controller.config.get("controller.database_directory"); + const features = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_scenario", "features.json"), + messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord), + ).bootstrap() + ); + + const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, features); + plugin.reconcileFeatures(); + + controller.subscriptions.handle(messages.FeatureUpdatedEvent, plugin.handleFeatureSubscription.bind(plugin)); + features.on("update", plugin.featuresUpdated.bind(plugin)); + + controller.handle(messages.SeedRequest, plugin.handleSeedRequest.bind(plugin)); + controller.handle(messages.FeatureListRequest, plugin.handleFeatureListRequest.bind(plugin)); + controller.handle(messages.FeatureUpdateRequest, plugin.handleFeatureUpdateRequest.bind(plugin)); + + controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + return plugin; } - async init() { - this.controller.handle(messages.SeedRequest, this.handleSeedRequest.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; } /** @@ -157,7 +232,7 @@ export class ControllerPlugin { } export default async function (context: ControllerPluginContext) { - await new ControllerPlugin(context).init(); + await ControllerPlugin.fromContext(context); } function newId(datastore: { has(id: number): boolean }) { diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts new file mode 100644 index 00000000..294ee9bf --- /dev/null +++ b/exp_scenario/features.ts @@ -0,0 +1,475 @@ +/** + * The features which can be configured from the web interface. Defaults must + * match the lua side, test/features.test.js checks this. + */ + +export type FeatureValue = boolean | number | string | string[] | null; + +/** A setting, shaped like a clusterio config field so the config inputs can render it */ +export type FeatureField = { + name: string, + title: string, + description: string, +} & ( + | ScalarField<{ type: "boolean", default: boolean }> + | ScalarField<{ type: "number", default: number | null, min?: number, unit?: string }> + | ScalarField<{ type: "string", default: string | null, enum?: string[] }> + /** Feature.list or Feature.set on the lua side, both are sent as a list */ + | { type: "string_list", default: string[] } +); + +type ScalarField = T & { + /** Allows null, the lua side uses Feature.optional */ + optional?: boolean, + /** Name of an input component registered with the web interface, such as "role" */ + inputComponent?: string, +}; + +export type Feature = { + name: string, + title: string, + description: string, + fields: FeatureField[], +}; + +function bool(name: string, title: string, description: string, value: boolean): FeatureField { + return { name, title, description, type: "boolean", default: value }; +} + +function num(name: string, title: string, description: string, value: number, min?: number, unit?: string): FeatureField { + return { name, title, description, type: "number", default: value, min, unit }; +} + +function str(name: string, title: string, description: string, value: string): FeatureField { + return { name, title, description, type: "string", default: value }; +} + +function list(name: string, title: string, description: string, value: string[]): FeatureField { + return { name, title, description, type: "string_list", default: value }; +} + +/** Id of a clusterio role, null when no role is picked */ +function role(name: string, title: string, description: string): FeatureField { + return { name, title, description, type: "number", default: null, optional: true, inputComponent: "role" }; +} + +export const features: Feature[] = [ + { + name: "afk_kick", + title: "AFK Kick", + description: "Kick everyone when nobody online is active", + fields: [ + bool("admin_as_active", "Admins count as active", "Admins are active regardless of how long they have been afk", true), + bool("trust_as_active", "Trusted players count as active", "Players with enough playtime are active regardless of how long they have been afk", true), + role("active_role_id", "Role counts as active", "Players with this role or higher are active regardless of how long they have been afk"), + num("afk_minutes", "AFK after", "Time without input before a player counts as afk", 10, 1, "minutes"), + num("kick_minutes", "Kick after", "Time without any active player before everyone is kicked", 30, 1, "minutes"), + num("trust_minutes", "Trusted after", "Playtime needed before a player counts as trusted", 600, 0, "minutes"), + ], + }, + { + name: "autofill", + title: "Autofill", + description: "Fill ammo and fuel into entities when they are built, configured by each player from the toolbar", + fields: [], + }, + { + name: "chat_auto_reply", + title: "Chat Auto Reply", + description: "Reply to key words in chat and run chat commands", + fields: [ + str("command_prefix", "Command prefix", "Prefix for chat commands, and for key words to reply to everyone", "!"), + ], + }, + { + name: "custom_start", + title: "Custom Start", + description: "Map settings applied when the map is created, and the items new players start with", + fields: [ + bool("skip_intro", "Skip intro", "Skip the freeplay intro", true), + bool("skip_victory", "Skip victory", "Skip the victory screen when a rocket is launched", true), + bool("friendly_fire", "Friendly fire", "Players can damage each other", false), + bool("disable_crashsite", "No crash site", "Do not create the crash site", true), + bool("enemy_expansion", "Enemy expansion", "Biters expand, in case the map settings file fails to load", false), + num("chart_radius", "Chart radius", "Tiles charted around spawn when the map starts", 320, 0, "tiles"), + ], + }, + { + name: "deconstruction_log", + title: "Deconstruction Log", + description: "Write actions of players without the bypass permission to log/deconstruction.log", + fields: [ + bool("decon_area", "Deconstructed areas", "Log when an area is deconstructed", true), + bool("built_entity", "Built entities", "Log when an entity is built", true), + bool("mined_entity", "Mined entities", "Log when an entity is mined", true), + bool("fired_rocket", "Rockets", "Log when a rocket is fired", true), + bool("fired_explosive_rocket", "Explosive rockets", "Log when an explosive rocket is fired", true), + bool("fired_nuke", "Nukes", "Log when a nuke is fired", true), + ], + }, + { + name: "degrading_tiles", + title: "Degrading Tiles", + description: "Tiles wear down as players walk on them and build on them", + fields: [ + num("weakness_value", "Weakness", "Lower values make tiles degrade sooner", 70, 1), + list("entities", "Degrading entities", "Entities which degrade the tiles under them when built", [ + "stone-furnace", "steel-furnace", "electric-furnace", "assembling-machine-1", "assembling-machine-2", "assembling-machine-3", + "beacon", "centrifuge", "chemical-plant", "oil-refinery", "storage-tank", "nuclear-reactor", + "steam-engine", "steam-turbine", "boiler", "heat-exchanger", "stone-wall", "gate", + "gun-turret", "laser-turret", "flamethrower-turret", "radar", "lab", "big-electric-pole", + "substation", "rocket-silo", "pumpjack", "electric-mining-drill", "roboport", "accumulator", + ]), + ], + }, + { + 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: "extra_logging", + title: "Extra Logging", + description: "Write rockets, deaths, research, joins, and leaves to a log file", + fields: [ + str("file_name", "File name", "Path of the log file within script-output", "log/logging.log"), + num("rocket_launch_display_rate", "Rocket log interval", "After the first few launches, log every this many rockets", 500, 1, "rockets"), + ], + }, + { + name: "help_bubbles", + title: "Help Bubbles", + description: "A compilatron at spawn which cycles through tips and the community links", + fields: [], + }, + { + name: "inventory_clear", + title: "Inventory Clear", + description: "Move the items of banned and kicked players to a chest at spawn", + fields: [], + }, + { + 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: "module_inserter", + title: "Module Inserter", + description: "A tool to fill machines with modules by selecting an area", + fields: [ + bool("copy_paste_module", "Copy modules", "Copying a machine also copies its modules", true), + bool("copy_paste_rotation", "Copy rotation", "Copying a machine also copies its rotation", false), + ], + }, + { + 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: "player_bonus", + title: "Player Bonus", + description: "Points players spend on personal bonuses, the bonuses live in config/player_bonus.lua", + fields: [ + num("points.base", "Base points", "The points of the points role, the standard value of each bonus adds up to this", 174, 0, "points"), + num("points.increase_percentage_per_role_level", "Points per role level", "Extra points as a fraction of the base for each role above the points role", 0.03, 0), + role("points.role_id", "Points role", "The role the base points apply to, without one every player gets the base"), + ], + }, + { + name: "pollution_grading", + title: "Pollution Grading", + description: "Scale the pollution overlay so it is not one red mess", + fields: [ + num("reference_point.x", "Reference X", "Where pollution is read from", 0), + num("reference_point.y", "Reference Y", "Where pollution is read from", 0), + num("max_scalar", "Max scale", "The scale between the true max and the shown max", 0.5, 0), + num("min_scalar", "Min scale", "The scale between the shown max and the shown min", 0.17, 0), + ], + }, + { + 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: "research", + title: "Research", + description: "Research effects, the per level limits live in config/research_data.lua", + fields: [ + bool("pollution_ageing_by_research", "Pollution ageing", "Pollution ages faster with each level of the bonus inventory researches", false), + bool("bonus_inventory.enabled", "Bonus inventory", "Extra inventory slots for each level of the researches below", true), + list("bonus_inventory.res", "Bonus researches", "Researches which grant the extra slots", [ + "mining-productivity", "mining-productivity-2", "mining-productivity-3", "mining-productivity-4", + ]), + num("bonus_inventory.rate", "Slots per level", "Extra slots for each research level", 5, 0, "slots"), + num("bonus_inventory.limit", "Slot limit", "The most extra slots a force can have", 20, 0, "slots"), + ], + }, + { + name: "research_milestones", + title: "Research Milestones", + description: "A GUI tracking research against target times, the targets live in config/research_data.lua", + fields: [ + str("log_file", "Log file", "Where the milestone times are written within script-output", "log/research.log"), + ], + }, + { + name: "rocket_info", + title: "Rocket Info", + description: "A GUI with rocket launch statistics, milestones, and build progress", + fields: [ + bool("show_stats", "Statistics section", "Show the statistics section", true), + bool("show_first_rocket", "First rocket", "Show when the first rocket was launched", true), + bool("show_last_rocket", "Last rocket", "Show when the last rocket was launched", true), + bool("show_fastest_rocket", "Fastest rocket", "Show the time taken for the fastest rocket", true), + bool("show_total_rockets", "Total rockets", "Show the total number of rockets launched", true), + bool("show_game_avg", "Game average", "Show the average across the entire map time", true), + bool("show_milestones", "Milestones section", "Show when each milestone rocket was launched", true), + bool("show_progress", "Progress section", "Show the build progress of each silo", true), + bool("allow_zoom_to_map", "Zoom to map", "Clicking a silo zooms the map to it", true), + ], + }, + { + name: "science_production", + title: "Science Production", + description: "A GUI with the production of each science pack", + fields: [ + bool("show_eta", "Show ETA", "Show the estimated time until the current research completes", true), + num("color_flux", "Colour threshold", "How much production may fluctuate before the icon changes colour", 0.1, 0), + ], + }, + { + name: "repair", + title: "Repair", + description: "The /repair command which revives ghosts and heals entities in an area", + fields: [ + bool("allow_ghost_revive", "Revive ghosts", "Build ghosts in the area instantly", true), + bool("allow_blueprint_repair", "Revive blueprints", "Also revive ghosts which were never built, not only those left by destroyed entities", false), + bool("allow_heal_entities", "Heal entities", "Heal entities in the area to full health", true), + ], + }, + { + 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__"), + ], + }, + { + name: "task_list", + title: "Task List", + description: "A shared list of tasks, adding and editing need the task list permissions", + fields: [ + bool("user_can_edit_own_tasks", "Own tasks editable", "The player who made a task can edit it without the edit permission", true), + ], + }, + { + name: "warp_list", + title: "Warp List", + description: "Warp points players can travel between, adding, editing, and bypassing limits need the warp list permissions", + fields: [ + num("minimum_distance", "Minimum distance", "Warps of a force can not be closer together than this", 100, 1, "tiles"), + num("cooldown_duration", "Cooldown", "Wait between warps", 60, 0, "seconds"), + num("standard_proximity_radius", "Warp radius", "How close a player must stand to a warp to use it", 4, 1, "tiles"), + num("spawn_proximity_radius", "Spawn radius", "How close a player must stand to spawn to use it as a warp", 20, 1, "tiles"), + bool("user_can_edit_own_warps", "Own warps editable", "The player who made a warp can edit it without the edit permission", false), + ], + }, + { + name: "vlayer", + title: "Virtual Layer", + description: "A shared virtual surface for solar panels, accumulators, and storage, the items live in config/vlayer_items.lua", + fields: [ + bool("unlimited_capacity", "Unlimited capacity", "Energy storage without accumulators", false), + bool("unlimited_surface_area", "Unlimited area", "Place items without landfill", false), + bool("modded_auto_downgrade", "Downgrade modded items", "Convert modded items to their base game equivalent, the originals can not be recovered", false), + bool("power_on_space", "Power on platforms", "Energy interfaces can be built on space platforms", false), + str("power_on_space_research.name", "Platform research", "The research needed to build energy interfaces on platforms", "research-productivity"), + num("power_on_space_research.level", "Platform research level", "The level of that research", 10, 0), + str("mimic_surface", "Mimic surface", "The surface the day cycle is copied from, empty for a fixed cycle", "nauvis"), + num("interface_limit.energy", "Energy interfaces", "More than one lets disconnected power networks receive power", 1, 0), + num("interface_limit.circuit", "Circuit interfaces", "How many circuit interfaces can exist", 20, 0), + num("interface_limit.storage_input", "Input interfaces", "How many storage input interfaces can exist", 20, 0), + num("interface_limit.storage_output", "Output interfaces", "More than zero allows item teleportation of the allowed items", 1, 0), + ], + }, +]; + +/** Check values against the fields of a feature, returning those which differ from their default */ +export function validateFeatureValues(name: string, values: Record) { + const feature = features.find(f => f.name === name); + if (!feature) { + throw new Error(`Unknown feature ${name}`); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(values)) { + const field = feature.fields.find(f => f.name === key); + if (!field) { + throw new Error(`Feature ${name} has no setting ${key}`); + } + const error = fieldValueError(field, value); + if (error) { + throw new Error(`Setting ${key} of ${name} ${error}`); + } + if (!isDefaultValue(field, value)) { + result[key] = value; + } + } + return result; +} + +/** Why a value does not fit a field, undefined when it does */ +function fieldValueError(field: FeatureField, value: FeatureValue) { + if (value === null) { + return field.type === "string_list" || !field.optional ? "can not be null" : undefined; + } + switch (field.type) { + case "string_list": + if (!Array.isArray(value) || value.some(item => typeof item !== "string")) { + return "must be a list of strings"; + } + return undefined; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) { + return "must be a finite number"; + } + if (field.min !== undefined && value < field.min) { + return `must be at least ${field.min}`; + } + return undefined; + case "string": + if (typeof value !== "string") { + return "must be a string"; + } + if (field.enum && !field.enum.includes(value)) { + return `must be one of ${field.enum.join(", ")}`; + } + return undefined; + default: + return typeof value === field.type ? undefined : `must be a ${field.type}`; + } +} + +/** Check if two values are equal, lists are compared by content. */ +export function sameValue(a: FeatureValue | undefined, b: FeatureValue | undefined) { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, index) => item === b[index]); + } + return a === b; +} + +export function isDefaultValue(field: FeatureField, value: FeatureValue) { + return sameValue(value, field.default); +} + +/** Split stored values into those which still fit a field and the names of the rest */ +export function pruneFeatureValues(feature: Feature, values: Record) { + const kept: Record = {}; + const dropped: string[] = []; + for (const [key, value] of Object.entries(values)) { + const field = feature.fields.find(f => f.name === key); + if (field && !fieldValueError(field, value)) { + kept[key] = value; + } else { + dropped.push(key); + } + } + return { kept, dropped }; +} diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 203c5721..5e4a721a 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -20,6 +20,9 @@ export const plugin: lib.PluginDeclaration = { messages: [ messages.SeedRequest, + messages.FeatureUpdatedEvent, + messages.FeatureListRequest, + messages.FeatureUpdateRequest, ], permissions: [ @@ -42,4 +45,7 @@ export const plugin: lib.PluginDeclaration = { ], webEntrypoint: "./web", + routes: [ + "/scenario_features", + ], }; diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index 95a41ff8..0e6a9da8 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -1,3 +1,86 @@ -// 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 { + /** Rcon is up and the lua side can take updates */ + started = false; + + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} + + static async fromContext(context: InstancePluginContext) { + const instance = context.instance; + const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); + + instance.handle(messages.FeatureUpdatedEvent, plugin.handleFeatureUpdatedEvent.bind(plugin)); + instance.hooks.start.attach(plugin.name, plugin.onStart.bind(plugin)); + instance.hooks.exit.attach(plugin.name, plugin.onExit.bind(plugin)); + instance.hooks.controllerConnectionEvent.attach(plugin.name, plugin.onControllerConnectionEvent.bind(plugin)); + return plugin; + } + + async onStart() { + this.started = true; + await this.syncFeatures(); + } + + async onExit() { + this.started = false; + } + + /** A new session means the controller restarted and forgot the subscription. */ + async onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { + if (event === "connect" && this.started) { + await this.syncFeatures(); + } + } + + /** Subscribe, then send every feature */ + async syncFeatures() { + await this.instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_scenario:${messages.FeatureUpdatedEvent.name}`, "subscribe", Date.now(), + )); + const features = await this.instance.sendTo("controller", new messages.FeatureListRequest()); + await this.luaSendFeatures(features); + } + + async handleFeatureUpdatedEvent(event: messages.FeatureUpdatedEvent) { + if (this.started) { + await this.luaSendFeatures(event.updates.filter(feature => !feature.isDeleted)); + } + } + + /** Every value of each feature is sent, so the lua side sets them all */ + async luaSendFeatures(records: messages.FeatureRecord[]) { + if (!records.length) { + return; + } + + const updates = records.map(record => { + const values: Record = { enabled: record.enabled }; + for (const field of features.find(feature => feature.name === record.id)?.fields ?? []) { + values[field.name] = field.name in record.values ? record.values[field.name] : field.default; + } + return { name: record.id, values }; + }); + const json = JSON.stringify(updates); + // Pick a long bracket the json does not contain + let level = "="; + while (json.includes(`]${level}]`)) { + level += "="; + } + await this.instance.sendRcon( + `/sc exp_scenario.features.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true, + ); + } +} + +export default async function (context: InstancePluginContext) { + await InstancePlugin.fromContext(context); +} diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index 9c778456..3438c061 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -1,3 +1,7 @@ +import * as lib from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; +import type { FeatureValue } from "./features.js"; + /** Create the roles and permission groups the scenario shipped with, see seed.ts. */ export class SeedRequest { declare ["constructor"]: typeof SeedRequest; @@ -9,3 +13,116 @@ export class SeedRequest { constructor() {} } + +const FeatureValueSchema = Type.Union([Type.Boolean(), Type.Number(), Type.String(), Type.Array(Type.String()), Type.Null()]); + +/** The config of one feature, see features.ts. Only values which differ from the default are stored. */ +export class FeatureRecord { + constructor( + /** Name of the feature in features.ts */ + public id: string, + public enabled: boolean, + public values: Record = {}, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + enabled: Type.Boolean(), + values: Type.Record(Type.String(), FeatureValueSchema), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + enabled: this.enabled, + values: this.values, + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.enabled, + json.values, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +export class FeatureUpdatedEvent { + declare ["constructor"]: typeof FeatureUpdatedEvent; + static plugin = "exp_scenario" as const; + static type = "event" as const; + static src = "controller" as const; + static dst = ["control", "instance"] as const; + static permission = "exp_scenario.config.view" as const; + + constructor( + public updates: FeatureRecord[], + ) {} + + static jsonSchema = Type.Object({ + updates: Type.Array(FeatureRecord.jsonSchema), + }); + + toJSON() { + return { updates: this.updates.map(feature => feature.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.updates.map(feature => FeatureRecord.fromJSON(feature))); + } +} + +/** List the config of every feature. */ +export class FeatureListRequest { + declare ["constructor"]: typeof FeatureListRequest; + static plugin = "exp_scenario" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_scenario.config.view" as const; + static Response = lib.jsonArray(FeatureRecord); +} + +/** Replace the config of a feature, values left out go back to their default. */ +export class FeatureUpdateRequest { + declare ["constructor"]: typeof FeatureUpdateRequest; + static plugin = "exp_scenario" as const; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "exp_scenario.config.edit" as const; + static Response = FeatureRecord; + + constructor( + public id: string, + public enabled: boolean, + public values: Record, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + enabled: Type.Boolean(), + values: Type.Record(Type.String(), FeatureValueSchema), + }); + + static fromJSON(json: Static) { + return new this(json.id, json.enabled, json.values); + } +} diff --git a/exp_scenario/module/commands/_authorities.lua b/exp_scenario/module/commands/_authorities.lua index 37093ad8..7379e4e4 100644 --- a/exp_scenario/module/commands/_authorities.lua +++ b/exp_scenario/module/commands/_authorities.lua @@ -5,6 +5,7 @@ Adds a permission authority which checks the clusterio permission for a command local Commands = require("modules/exp_commands") local add, allow, deny = Commands.add_permission_authority, Commands.status.success, Commands.status.unauthorised +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local player_has_permission = Roles.player_has_permission @@ -22,6 +23,17 @@ authorities.exp_permission = end end) +--- Commands flagged with a feature are denied while it is disabled, see features.lua +authorities.feature_enabled = + add(function(player, command) + local feature = command.flags.feature + if feature and not feature:is_enabled() then + return deny{ "exp-commands-authorities_feature.deny" } + else + return allow() + end + end) + Roles.define_permission_trigger("core.admin", function(player, state) if state then Commands.unlock_system_commands(player.name) diff --git a/exp_scenario/module/commands/lawnmower.lua b/exp_scenario/module/commands/lawnmower.lua index 0dbba6f6..26cc33fa 100644 --- a/exp_scenario/module/commands/lawnmower.lua +++ b/exp_scenario/module/commands/lawnmower.lua @@ -4,9 +4,13 @@ Adds a command that clean up biter corpse and nuclear hole local AABB = require("modules/exp_util/aabb") local Commands = require("modules/exp_commands") +local Feature = require("modules/exp_scenario/features") local Selection = require("modules/exp_util/selection") local SelectArea = Selection.connect("ExpCommand_Lawnmower") -local config = require("modules.exp_legacy.config.lawnmower") + +local feature, config = Feature.register("lawnmower", { + destroy_decoratives = false, -- remove decoratives under entities when they are built +}) --- @class ExpCommand_Lawnmower.commands local commands = {} @@ -15,6 +19,7 @@ local commands = {} --- @class ExpCommands_Lawnmower.commands.lawnmower: ExpCommand --- @overload fun(player: LuaPlayer) commands.lawnmower = Commands.new("lawnmower", { "exp-commands_lawnmower.description" }) + :add_flags{ feature = feature } :register(function(player) if SelectArea:stop(player) then return Commands.status.success{ "exp_util.selection_exit", { "exp-commands_lawnmower.selection-name" } } @@ -51,6 +56,7 @@ end) --- @param event EventData.on_built_entity | EventData.on_robot_built_entity | EventData.script_raised_built | EventData.script_raised_revive local function destroy_decoratives(event) + if not config.destroy_decoratives then return end local entity = event.entity if entity.type ~= "entity-ghost" and entity.type ~= "tile-ghost" and entity.prototype.selectable_in_game then entity.surface.destroy_decoratives{ area = entity.selection_box } @@ -58,16 +64,13 @@ local function destroy_decoratives(event) end local e = defines.events -local events = {} -if config.destroy_decoratives then - events[e.on_built_entity] = destroy_decoratives - events[e.on_robot_built_entity] = destroy_decoratives - events[e.script_raised_built] = destroy_decoratives - events[e.script_raised_revive] = destroy_decoratives -end - -return { - events = events, +return feature:guard{ + events = { + [e.on_built_entity] = destroy_decoratives, + [e.on_robot_built_entity] = destroy_decoratives, + [e.script_raised_built] = destroy_decoratives, + [e.script_raised_revive] = destroy_decoratives, + }, commands = commands, } diff --git a/exp_scenario/module/commands/protected_entities.lua b/exp_scenario/module/commands/protected_entities.lua index 34b66783..b35861e2 100644 --- a/exp_scenario/module/commands/protected_entities.lua +++ b/exp_scenario/module/commands/protected_entities.lua @@ -153,6 +153,17 @@ SelectArea:on_alt_selection(function(event) end end) +--- The keys of a set as a list, find_entities_filtered takes a list of names +--- @param set table +--- @return string[] +local function set_keys(set) + local keys = {} + for key in pairs(set) do + keys[#keys + 1] = key + end + return keys +end + --- When selection starts show all protected entities and protected areas local function on_player_selection_start(event) local player = game.players[event.player_index] @@ -166,15 +177,17 @@ local function on_player_selection_start(event) end -- Show always protected entities by name - if #EntityProtection.protected_entity_names > 0 then - for _, entity in pairs(surface.find_entities_filtered{ name = EntityProtection.protected_entity_names, force = player.force }) do + local protected_entity_names = set_keys(EntityProtection.config.always_protected_names) + if #protected_entity_names > 0 then + for _, entity in pairs(surface.find_entities_filtered{ name = protected_entity_names, force = player.force }) do show_protected_entity(player, entity) end end -- Show always protected entities by type - if #EntityProtection.protected_entity_types > 0 then - for _, entity in pairs(surface.find_entities_filtered{ type = EntityProtection.protected_entity_types, force = player.force }) do + local protected_entity_types = set_keys(EntityProtection.config.always_protected_types) + if #protected_entity_types > 0 then + for _, entity in pairs(surface.find_entities_filtered{ type = protected_entity_types, force = player.force }) do show_protected_entity(player, entity) end end diff --git a/exp_scenario/module/commands/repair.lua b/exp_scenario/module/commands/repair.lua index 6e886387..10ba9722 100644 --- a/exp_scenario/module/commands/repair.lua +++ b/exp_scenario/module/commands/repair.lua @@ -4,9 +4,15 @@ Adds a command that allows an admin to repair and revive a large area local AABB = require("modules/exp_util/aabb") local Commands = require("modules/exp_commands") -local config = require("modules.exp_legacy.config.repair") --- @dep config.repair +local Feature = require("modules/exp_scenario/features") local Selection = require("modules/exp_util/selection") -local SelectArea = Selection.connect("ExpCommand_Waterfill") +local SelectArea = Selection.connect("ExpCommand_Repair") + +local feature, config = Feature.register("repair", { + allow_blueprint_repair = false, -- revive ghosts which were never built, not only those left by destroyed entities + allow_ghost_revive = true, -- revive ghosts in the area + allow_heal_entities = true, -- heal entities in the area to full health +}) --- @class ExpCommands_Repair.commands local commands = {} @@ -14,6 +20,7 @@ local commands = {} --- Toggle player selection mode --- @class ExpCommands_Repair.commands.repair: ExpCommand commands.repair = Commands.new("repair", { "exp-commands_repair.description" }) + :add_flags{ feature = feature } :register(function(player) if SelectArea:stop(player) then return Commands.status.success{ "exp_util.selection_exit", { "exp-commands_repair.selection-name" } } @@ -22,7 +29,7 @@ commands.repair = Commands.new("repair", { "exp-commands_repair.description" }) return Commands.status.success{ "exp_util.selection_enter", { "exp-commands_repair.selection-name" } } end) ---- When an area is selected to be converted to water +--- When an area is selected to be repaired SelectArea:on_selection(function(event) local player = assert(game.get_player(event.player_index)) local area = AABB.expand(event.area) diff --git a/exp_legacy/module/config/gui/autofill.lua b/exp_scenario/module/config/autofill.lua similarity index 98% rename from exp_legacy/module/config/gui/autofill.lua rename to exp_scenario/module/config/autofill.lua index bcade17c..d44a53ef 100644 --- a/exp_legacy/module/config/gui/autofill.lua +++ b/exp_scenario/module/config/autofill.lua @@ -3,7 +3,7 @@ local config = { -- General config - icon = "item/piercing-rounds-magazine", -- @setting icon that will be used for the toolbar + icon = "item/piercing-rounds-magazine", -- that will be used for the toolbar categories = { ammo = "ammo", fuel = "fuel", diff --git a/exp_legacy/module/config/chat_reply.lua b/exp_scenario/module/config/chat_auto_reply.lua similarity index 90% rename from exp_legacy/module/config/chat_reply.lua rename to exp_scenario/module/config/chat_auto_reply.lua index 7b624fcf..a9e627b9 100644 --- a/exp_legacy/module/config/chat_reply.lua +++ b/exp_scenario/module/config/chat_auto_reply.lua @@ -1,7 +1,8 @@ --- This file defines the different triggers for the chat bot --- @config Chat-Reply +-- @config Chat-Auto-Reply local ExpUtil = require("modules/exp_util") +local Feature = require("modules/exp_scenario/features") local Async = require("modules/exp_util/async") local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail @@ -24,9 +25,9 @@ local afk_time_units = { seconds = true, } -return { +return Feature.register("chat_auto_reply", { --- @type table - messages = { --- @setting messages will trigger when ever the word is said + messages = { -- will trigger when ever the word is said ["discord"] = { "info.discord", config_server_detail["discord"] }, ["expgaming"] = { "info.website", config_server_detail["website"] }, ["website"] = { "info.website", config_server_detail["website"] }, @@ -64,12 +65,10 @@ return { return { "exp_chat-auto-reply.reply-afk", max.name, ExpUtil.format_time_locale(max.afk_time, "long", afk_time_units) } end, }, - allow_command_prefix_for_messages = true, --- @setting allow_command_prefix_for_messages when true any message trigger will print to all player when prefixed - command_admin_only = false, --- @setting command_admin_only when true will only allow chat commands for admins - command_permission = "exp_scenario.chat.commands", --- @setting command_permission the permission used to allow command prefixes - command_prefix = "!", --- @setting command_prefix prefix used for commands below and to print to all players (if enabled above) + allow_command_prefix_for_messages = true, -- when true any message trigger will print to all player when prefixed + command_prefix = "!", -- prefix used for commands below and to print to all players (if enabled above) --- @type table - commands = { --- @setting commands will trigger only when command prefix is given + commands = { -- will trigger only when command prefix is given ["dev"] = { "exp_chat-auto-reply.reply-dev" }, ["magic"] = { "exp_chat-auto-reply.reply-magic" }, ["aids"] = { "exp_chat-auto-reply.reply-aids" }, @@ -134,4 +133,4 @@ return { return { locale_reply, { "exp_chat-auto-reply.reply-beer-1" } } end, }, -} +}) diff --git a/exp_scenario/module/config/module_inserter_machines.lua b/exp_scenario/module/config/module_inserter_machines.lua new file mode 100644 index 00000000..c328725a --- /dev/null +++ b/exp_scenario/module/config/module_inserter_machines.lua @@ -0,0 +1,82 @@ +--- The machines the module inserter can fill, and the modules it puts in them, by mod set +-- @config Module-Inserter-Machines + +return { + ["base"] = { + ["electric-mining-drill"] = { + ["module"] = "efficiency-module", + ["prod"] = true, + }, + ["pumpjack"] = { + ["module"] = "efficiency-module", + ["prod"] = true, + }, + ["assembling-machine-2"] = { + ["module"] = "productivity-module", + ["prod"] = true, + }, + ["assembling-machine-3"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["electric-furnace"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["beacon"] = { + ["module"] = "speed-module-3", + ["prod"] = false, + }, + ["oil-refinery"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["chemical-plant"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["centrifuge"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["lab"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["rocket-silo"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + } + }, + ["space-age"] = { + ["big-mining-drill"] = { + ["module"] = "efficiency-module", + ["prod"] = true, + }, + ["biochamber"] = { + ["module"] = "efficiency-module", + ["prod"] = true, + }, + ["electromagnetic-plant"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["cryogenic-plant"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["biolab"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["foundry"] = { + ["module"] = "productivity-module-3", + ["prod"] = true, + }, + ["recycler"] = { + ["module"] = "quality-module-3", + ["prod"] = true, + } + } + +} diff --git a/exp_scenario/module/config/player_bonus.lua b/exp_scenario/module/config/player_bonus.lua new file mode 100644 index 00000000..ce688d05 --- /dev/null +++ b/exp_scenario/module/config/player_bonus.lua @@ -0,0 +1,63 @@ +--- The bonuses players can spend their points on, see gui/player_bonus.lua +-- @config Player-Bonus + +return { + { + name = "character_mining_speed_modifier", + scale = 1, + cost = 10, + max_value = 4, + value_step = 0.5, + is_percentage = true, + }, + { + name = "character_running_speed_modifier", + scale = 1, + cost = 60, + max_value = 2, + value_step = 0.25, + is_percentage = true, + }, + { + name = "character_crafting_speed_modifier", + scale = 1, + cost = 4, + max_value = 12, + value_step = 1, + is_percentage = true, + }, + { + name = "character_inventory_slots_bonus", + scale = 10, + cost = 2, + max_value = 100, + value_step = 10, + }, + { + name = "character_health_bonus", + scale = 50, + cost = 4, + max_value = 300, + value_step = 50, + }, + { + name = "character_reach_distance_bonus", + scale = 1, + cost = 1, + max_value = 16, + value_step = 2, + combined_bonus = { + "character_resource_reach_distance_bonus", + "character_build_distance_bonus", + }, + }, + { + name = "personal_battery_recharge", + scale = 4, + cost = 40, + max_value = 8, + value_step = 1, + is_special = true, + }, + +} diff --git a/exp_legacy/module/config/research.lua b/exp_scenario/module/config/research_data.lua similarity index 85% rename from exp_legacy/module/config/research.lua rename to exp_scenario/module/config/research_data.lua index 73e710d8..ab130149 100644 --- a/exp_legacy/module/config/research.lua +++ b/exp_scenario/module/config/research_data.lua @@ -1,40 +1,20 @@ ---- Res Settings --- @config Research +--- Research data by mod set, the first set in mod_set_lookup whose mod is active is used +-- @config Research-Data -return { - enabled = true, - pollution_ageing_by_research = false, - queue_amount = 3, - mod_set = "base", +local data = { mod_set_lookup = { "space-age", }, - -- this enable 20 more inventory for each mining productivity level up to 4 - bonus_inventory = { - enabled = true, - res = { - -- Mining Productivity - ["mining-productivity"] = true, - ["mining-productivity-2"] = true, - ["mining-productivity-3"] = true, - ["mining-productivity-4"] = true, + final_res = { + ["base"] = { + ["name"] = "mining-productivity-4", + ["level"] = 4 }, - name = "character_inventory_slots_bonus", - rate = 5, - limit = 20, - }, - file = { - name = "log/research.log", - final_res = { - ["base"] = { - ["name"] = "mining-productivity-4", - ["level"] = 4 - }, - ["space-age"] = { - ["name"] = "mining-productivity-3", - ["level"] = 3 - } + ["space-age"] = { + ["name"] = "mining-productivity-3", + ["level"] = 3 } + }, milestone = { ["base"] = { @@ -177,3 +157,13 @@ return { -- ["atomic-bomb"] = 1 }, } + +data.mod_set = "base" +for _, mod_name in ipairs(data.mod_set_lookup) do + if script.active_mods[mod_name] then + data.mod_set = mod_name + break + end +end + +return data diff --git a/exp_legacy/module/config/spawn_area.lua b/exp_scenario/module/config/spawn_area.lua similarity index 93% rename from exp_legacy/module/config/spawn_area.lua rename to exp_scenario/module/config/spawn_area.lua index 72ee8c8e..605ac94f 100644 --- a/exp_legacy/module/config/spawn_area.lua +++ b/exp_scenario/module/config/spawn_area.lua @@ -1,41 +1,42 @@ --- Used to config the spawn generation settings yes there is alot here i know just ignore the long tables at the end (they were generated with a command) -- @config Spawn-Area -return { - spawn_area = { --- @setting spawn_area Settings relating to the whole spawn area +local Feature = require("modules/exp_scenario/features") + +return Feature.register("spawn_area", { + spawn_area = { -- Settings relating to the whole spawn area -- Enable predefined patches: 128, else: 32 - deconstruction_radius = 20, -- @setting deconstruction_radius All entities within this radius will be removed + deconstruction_radius = 20, -- All entities within this radius will be removed tile_radius = 20, - deconstruction_tile = "concrete", --- @setting deconstruction_tile Tile to be placed in the deconstruction radius, use nil for map gen - landfill_radius = 50, --- @setting pattern_radius All water within this radius will be land filled + deconstruction_tile = "concrete", -- Tile to be placed in the deconstruction radius, use nil for map gen + landfill_radius = 50, -- All water within this radius will be land filled }, - turrets = { --- @setting turrets Settings relating to adding turrets to spawn - enabled = true, --- @setting enabled Whether turrets will be added to spawn - ammo_type = "uranium-rounds-magazine", --- @setting ammo_type The ammo type that will be used during refills - refill_time = 60 * 60 * 5, --- @setting refill_time The time in ticks between each refill of the turrets, only change if having lag issues - offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to turrets - locations = { --- @setting locations The locations of all turrets, this list can change during runtime + turrets = { -- Settings relating to adding turrets to spawn + enabled = true, -- Whether turrets will be added to spawn + ammo_type = "uranium-rounds-magazine", -- The ammo type that will be used during refills + offset = { x = 0, y = 0 }, -- The position offset to apply to turrets + locations = { -- The locations of all turrets, this list can change during runtime { -3, -3 }, { 3, -3 }, { -3, 3 }, { 3, 3 }, }, }, - afk_belts = { --- @setting afk_belts Settings relating to adding afk belts to spawn - enabled = true, --- @setting enabled Whether afk belts will be added to spawn - belt_type = "transport-belt", --- @setting belt_type The belt to be used as afk belts - protected = true, --- @setting protected Whether belts will be protected from player interaction - offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to afk belts - locations = { --- @setting locations The locations to spawn afk belts at, given as the top left position + afk_belts = { -- Settings relating to adding afk belts to spawn + enabled = true, -- Whether afk belts will be added to spawn + belt_type = "transport-belt", -- The belt to be used as afk belts + protected = true, -- Whether belts will be protected from player interaction + offset = { x = 0, y = 0 }, -- The position offset to apply to afk belts + locations = { -- The locations to spawn afk belts at, given as the top left position { -5, -5 }, { 5, -5 }, { -5, 5 }, { 5, 5 }, }, }, - water = { --- @setting water Settings relating to adding water to spawn - enabled = true, --- @setting enabled Whether water tiles will be added to spawn - water_tile = "water-mud", --- @setting water_tile The tile to be used as the water tile - offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to water tiles - locations = { --- @setting locations The location of the water tiles {x,y} + water = { -- Settings relating to adding water to spawn + enabled = true, -- Whether water tiles will be added to spawn + water_tile = "water-mud", -- The tile to be used as the water tile + offset = { x = 0, y = 0 }, -- The position offset to apply to water tiles + locations = { -- The location of the water tiles {x,y} -- Each is a 3x3 with the closest tile to 0,0 removed { 7, 8 }, { 7, 9 }, { 8, 7 }, { 8, 8 }, { 8, 9 }, { 9, 7 }, { 9, 8 }, { 9, 9 }, -- Bottom Right { 7, -9 }, { 7, -10 }, { 8, -8 }, { 8, -9 }, { 8, -10 }, { 9, -8 }, { 9, -9 }, { 9, -10 }, -- Top Right @@ -43,12 +44,12 @@ return { { -8, 8 }, { -8, 9 }, { -9, 7 }, { -9, 8 }, { -9, 9 }, { -10, 7 }, { -10, 8 }, { -10, 9 }, -- Bottom Left }, }, - entities = { --- @setting entities Settings relating to adding entities to spawn - enabled = true, --- @setting enabled Whether entities will be added to spawn - protected = true, --- @setting protected Whether entities will be protected from player interaction - operable = true, --- @setting operable Whether entities can be opened by players, must be true if chests are used - offset = { x = 0, y = -2 }, --- @setting offset The position offset to apply to entities - locations = { --- @setting locations The location and names of entities {name,x,y} + entities = { -- Settings relating to adding entities to spawn + enabled = true, -- Whether entities will be added to spawn + protected = true, -- Whether entities will be protected from player interaction + operable = true, -- Whether entities can be opened by players, must be true if chests are used + offset = { x = 0, y = -2 }, -- The position offset to apply to entities + locations = { -- The location and names of entities {name,x,y} { "stone-wall", -10, -5 }, { "stone-wall", -10, -4 }, { "stone-wall", -10, -3 }, { "stone-wall", -10, -2 }, { "stone-wall", -10, -1 }, { "stone-wall", -10, 0 }, { "stone-wall", -10, 3 }, { "stone-wall", -10, 4 }, { "stone-wall", -10, 5 }, { "stone-wall", -10, 6 }, { "stone-wall", -10, 7 }, { "stone-wall", -10, 8 }, { "small-lamp", -8, -4 }, { "small-lamp", -8, -1 }, { "iron-chest", -8, 0 }, { "iron-chest", -8, 3 }, { "small-lamp", -8, 4 }, { "small-lamp", -8, 7 }, { "stone-wall", -7, -8 }, { "small-electric-pole", -7, -2 }, { "iron-chest", -7, 0 }, { "iron-chest", -7, 3 }, { "small-electric-pole", -7, 5 }, { "stone-wall", -7, 11 }, { "stone-wall", -6, -8 }, { "small-lamp", -6, -6 }, @@ -66,10 +67,10 @@ return { }, }, pattern = { - enabled = true, --- @setting enabled Whether pattern tiles will be added to spawn - pattern_tile = "stone-path", --- @setting pattern_tile The tile to be used for the pattern - offset = { x = 0, y = -2 }, --- @setting offset The position offset to apply to pattern tiles - locations = { --- @setting locations The location of the pattern tiles {x,y} + enabled = true, -- Whether pattern tiles will be added to spawn + pattern_tile = "stone-path", -- The tile to be used for the pattern + offset = { x = 0, y = -2 }, -- The position offset to apply to pattern tiles + locations = { -- The location of the pattern tiles {x,y} { -49, -3 }, { -49, -2 }, { -49, 1 }, { -49, 2 }, { -49, 5 }, { -49, 6 }, { -48, -4 }, { -48, -3 }, { -48, -2 }, { -48, 1 }, { -48, 2 }, { -48, 5 }, { -48, 6 }, { -48, 7 }, { -47, -7 }, { -47, -6 }, { -47, -5 }, { -47, -4 }, { -47, -3 }, { -47, -2 }, { -47, 5 }, { -47, 6 }, { -47, 7 }, { -47, 8 }, { -47, 9 }, { -47, 10 }, { -46, -8 }, { -46, -7 }, { -46, -6 }, { -46, -5 }, { -46, -4 }, { -46, -3 }, { -46, -2 }, { -46, -1 }, { -46, 4 }, { -46, 5 }, { -46, 6 }, { -46, 7 }, { -46, 8 }, { -46, 9 }, { -46, 10 }, { -46, 11 }, { -45, -17 }, { -45, -16 }, { -45, -15 }, { -45, -14 }, { -45, -13 }, { -45, -12 }, { -45, -9 }, { -45, -8 }, { -45, -7 }, { -45, -2 }, { -45, -1 }, { -45, 0 }, { -45, 1 }, { -45, 2 }, { -45, 3 }, { -45, 4 }, { -45, 5 }, { -45, 10 }, { -45, 11 }, { -45, 12 }, { -45, 15 }, { -45, 16 }, { -45, 17 }, { -45, 18 }, { -45, 19 }, { -45, 20 }, { -44, -18 }, { -44, -17 }, { -44, -16 }, { -44, -15 }, { -44, -14 }, { -44, -13 }, { -44, -12 }, { -44, -9 }, { -44, -8 }, { -44, -1 }, { -44, 0 }, { -44, 1 }, { -44, 2 }, { -44, 3 }, { -44, 4 }, { -44, 11 }, { -44, 12 }, { -44, 15 }, { -44, 16 }, { -44, 17 }, { -44, 18 }, { -44, 19 }, @@ -242,8 +243,7 @@ return { resource_refill_nearby = { enabled = false, range = 128, - refill_time = 36000, - resources_name = { + resources_name = Feature.list{ "iron-ore", "copper-ore", "stone", @@ -252,4 +252,4 @@ return { }, amount = { 2500, 4000 }, }, -} +}) diff --git a/exp_legacy/module/config/advanced_start.lua b/exp_scenario/module/config/starting_items.lua similarity index 52% rename from exp_legacy/module/config/advanced_start.lua rename to exp_scenario/module/config/starting_items.lua index 31fb6154..5511c7d5 100644 --- a/exp_legacy/module/config/advanced_start.lua +++ b/exp_scenario/module/config/starting_items.lua @@ -1,14 +1,12 @@ ---- This file is used to setup the map starting settings and the items players will start with --- @config Advanced-Start +--- The items players start with, and how many depending on what the factory has made +-- @config Starting-Items --- These are called factories because they return another function -- use these as a simple methods of adding new items -- they will do most of the work for you -- ['item-name'] = factory(params) --- luacheck:ignore 212/amount_made 212/items_made 212/player -- Use these to adjust for ticks ie game.tick < 5*minutes --- luacheck:ignore 211/seconds 211/minutes 211/hours local seconds, minutes, hours = 60, 3600, 216000 --- Use to make a split point for the number of items given based on time @@ -72,41 +70,34 @@ end hours, minutes, seconds are the number of ticks in each unit of time ]] +--- @type table return { - skip_intro = true, --- @setting skip_intro skips the intro given in the default factorio free play scenario - skip_victory = true, --- @setting skip_victory will skip the victory screen when a rocket is launched - friendly_fire = false, --- @setting friendly_fire weather players will be able to attack each other on the same force - disable_crashsite = true, --- @setting disable_crashsite weather to disable creation of the crashsite - enemy_expansion = false, --- @setting enemy_expansion a catch all for in case the map settings file fails to load - chart_radius = 10 * 32, --- @setting chart_radius the number of tiles that will be charted when the map starts - items = { --- @setting items items and there condition for being given - -- ['item-name'] = function(amount_made, production_stats, player) return end -- 0 means no items given - -- Plates - ["iron-plate"] = scale_amount_made(100, 10, 10), - ["copper-plate"] = scale_amount_made(100, 0, 8), - ["steel-plate"] = scale_amount_made(100, 0, 4), - -- Secondary Items - ["electronic-circuit"] = scale_amount_made(1000, 0, 6), - ["iron-gear-wheel"] = scale_amount_made(1000, 0, 6), - -- Starting Items - ["burner-mining-drill"] = cutoff_time(10 * minutes, 4, 0), - ["stone-furnace"] = cutoff_time(10 * minutes, 4, 0), - -- Armor - ["light-armor"] = cutoff_amount_made_unless(5, 0, 1, "heavy-armor", 5), - ["heavy-armor"] = cutoff_amount_made(5, 0, 1), - -- Weapon - ["pistol"] = cutoff_amount_made_unless(0, 1, 1, "submachine-gun", 5), - ["submachine-gun"] = cutoff_amount_made(5, 0, 1), - -- Ammo - ["firearm-magazine"] = cutoff_amount_made_unless(100, 10, 0, "piercing-rounds-magazine", 100), - ["piercing-rounds-magazine"] = cutoff_amount_made(100, 0, 10), - --[[ - ['construction-robot'] = 10, - ['modular-armor'] = 1, - ['solar-panel-equipment'] = 16, - ['belt-immunity-equipment'] = 1, - ['battery-equipment'] = 2, - ['personal-roboport-equipment'] = 1 - ]] - } + -- ['item-name'] = function(amount_made, production_stats, player) return end -- 0 means no items given + -- Plates + ["iron-plate"] = scale_amount_made(100, 10, 10), + ["copper-plate"] = scale_amount_made(100, 0, 8), + ["steel-plate"] = scale_amount_made(100, 0, 4), + -- Secondary Items + ["electronic-circuit"] = scale_amount_made(1000, 0, 6), + ["iron-gear-wheel"] = scale_amount_made(1000, 0, 6), + -- Starting Items + ["burner-mining-drill"] = cutoff_time(10 * minutes, 4, 0), + ["stone-furnace"] = cutoff_time(10 * minutes, 4, 0), + -- Armor + ["light-armor"] = cutoff_amount_made_unless(5, 0, 1, "heavy-armor", 5), + ["heavy-armor"] = cutoff_amount_made(5, 0, 1), + -- Weapon + ["pistol"] = cutoff_amount_made_unless(0, 1, 1, "submachine-gun", 5), + ["submachine-gun"] = cutoff_amount_made(5, 0, 1), + -- Ammo + ["firearm-magazine"] = cutoff_amount_made_unless(100, 10, 0, "piercing-rounds-magazine", 100), + ["piercing-rounds-magazine"] = cutoff_amount_made(100, 0, 10), + --[[ + ['construction-robot'] = 10, + ['modular-armor'] = 1, + ['solar-panel-equipment'] = 16, + ['belt-immunity-equipment'] = 1, + ['battery-equipment'] = 2, + ['personal-roboport-equipment'] = 1 + ]] } diff --git a/exp_scenario/module/config/vlayer_items.lua b/exp_scenario/module/config/vlayer_items.lua new file mode 100644 index 00000000..75e9449f --- /dev/null +++ b/exp_scenario/module/config/vlayer_items.lua @@ -0,0 +1,147 @@ +--- The items the vlayer can hold and what they do, see control/vlayer.lua +-- @config Vlayer-Items + +return { +--- @class Vlayer.ItemProperties +--- @field starting_value number +--- @field required_area number +--- @field production number? +--- @field discharge number? +--- @field capacity number? +--- @field surface_area number? +--- @field fuel_value number? +--- @field power boolean? +--- @field modded boolean? Set when the item is registered from a modded equivalent +--- @field base_game_equivalent string? +--- @field multiplier number? + +--- @type table +allowed_items = { -- List of all items allowed in vlayer storage and their properties + --[[ + Allowed properties: + starting_value : The amount of the item placed into the vlayer on game start, ignores area requirements + required_area : When greater than 0 the items properties are not applied unless their is sufficient surplus surface area + production : The energy production of the item in MW, used for solar panels + discharge : The energy discharge of the item in MW, used for accumulators + capacity : The energy capacity of the item in MJ, used for accumulators + surface_area : The surface area provided by the item, used for landfill + ]] + ["solar-panel"] = { + starting_value = 0, + required_area = 9, + production = 0.06, -- MW + }, + ["accumulator"] = { + starting_value = 0, + required_area = 4, + discharge = 0.3, -- MW + capacity = 5, -- MJ + }, + ["landfill"] = { + starting_value = 0, + required_area = 0, + surface_area = 20, -- Tiles + }, + ["wood"] = { + starting_value = 0, + required_area = 0, + surface_area = 0, + fuel_value = 2, -- MJ + power = true, -- turn all wood to power to reduce trash + }, + ["coal"] = { + starting_value = 0, + required_area = 0, + surface_area = 0, + fuel_value = 4, -- MJ + power = false, -- turn all coal to power to reduce trash + }, + ["solid-fuel"] = { + starting_value = 0, + required_area = 0, + surface_area = 0, + fuel_value = 12, -- MJ + power = false, -- turn all solid fuel to power to reduce trash + }, + ["rocket-fuel"] = { + starting_value = 0, + required_area = 0, + surface_area = 0, + fuel_value = 100, -- MJ + power = false, -- turn all rocket fuel to power to reduce trash + } +}, + +modded_items = { -- List of all modded items allowed in vlayer storage and their base game equivalent + ["solar-panel-2"] = { + starting_value = 0, + base_game_equivalent = "solar-panel", + multiplier = 4, + }, + ["solar-panel-3"] = { + starting_value = 0, + base_game_equivalent = "solar-panel", + multiplier = 16, + }, + ["solar-panel-4"] = { + starting_value = 0, + base_game_equivalent = "solar-panel", + multiplier = 64, + }, + ["solar-panel-5"] = { + starting_value = 0, + base_game_equivalent = "solar-panel", + multiplier = 256, + }, + ["solar-panel-6"] = { + starting_value = 0, + base_game_equivalent = "solar-panel", + multiplier = 1024, + }, + ["solar-panel-7"] = { + starting_value = 0, + base_game_equivalent = "solar-panel", + multiplier = 4096, + }, + ["solar-panel-8"] = { + starting_value = 0, + base_game_equivalent = "solar-panel", + multiplier = 16384, + }, + ["accumulator-2"] = { + starting_value = 0, + base_game_equivalent = "accumulator", + multiplier = 4, + }, + ["accumulator-3"] = { + starting_value = 0, + base_game_equivalent = "accumulator", + multiplier = 16, + }, + ["accumulator-4"] = { + starting_value = 0, + base_game_equivalent = "accumulator", + multiplier = 64, + }, + ["accumulator-5"] = { + starting_value = 0, + base_game_equivalent = "accumulator", + multiplier = 256, + }, + ["accumulator-6"] = { + starting_value = 0, + base_game_equivalent = "accumulator", + multiplier = 1024, + }, + ["accumulator-7"] = { + starting_value = 0, + base_game_equivalent = "accumulator", + multiplier = 4096, + }, + ["accumulator-8"] = { + starting_value = 0, + base_game_equivalent = "accumulator", + multiplier = 16384, + }, +} +} diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index db632016..a7702f6d 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -11,6 +11,7 @@ require("modules/exp_scenario/commands/_types") --- Commands with events add(require("modules/exp_scenario/commands/protected_entities")) add(require("modules/exp_scenario/commands/protected_tags")) +add(require("modules/exp_scenario/commands/lawnmower")) --- Commands require("modules/exp_scenario/commands/admin_chat") @@ -24,7 +25,6 @@ require("modules/exp_scenario/commands/enemy") require("modules/exp_scenario/commands/home") require("modules/exp_scenario/commands/jail") require("modules/exp_scenario/commands/kill") -require("modules/exp_scenario/commands/lawnmower") require("modules/exp_scenario/commands/locate") require("modules/exp_scenario/commands/me") require("modules/exp_scenario/commands/rainbow") @@ -43,13 +43,12 @@ require("modules/exp_scenario/commands/waterfill") add(require("modules/exp_scenario/control/afk_kick")) add(require("modules/exp_scenario/control/bonus")) add(require("modules/exp_scenario/control/chat_auto_reply")) -add(require("modules/exp_scenario/control/chat_popup")) add(require("modules/exp_scenario/control/custom_start")) -add(require("modules/exp_scenario/control/damage_popups")) add(require("modules/exp_scenario/control/death_markers")) add(require("modules/exp_scenario/control/deconstruction_log")) add(require("modules/exp_scenario/control/degrading_tiles")) add(require("modules/exp_scenario/control/discord_alerts")) +add(require("modules/exp_scenario/control/features")) add(require("modules/exp_scenario/control/extra_logging")) add(require("modules/exp_scenario/control/fast_deconstruction")) add(require("modules/exp_scenario/control/help_bubbles")) @@ -58,6 +57,7 @@ add(require("modules/exp_scenario/control/inventory_clear")) add(require("modules/exp_scenario/control/mine_depletion")) add(require("modules/exp_scenario/control/nuke_protection")) add(require("modules/exp_scenario/control/pollution_grading")) +add(require("modules/exp_scenario/control/popups")) add(require("modules/exp_scenario/control/protection")) add(require("modules/exp_scenario/control/protection_jail")) add(require("modules/exp_scenario/control/report_jail")) diff --git a/exp_scenario/module/control/afk_kick.lua b/exp_scenario/module/control/afk_kick.lua index 24f9b378..2f213e71 100644 --- a/exp_scenario/module/control/afk_kick.lua +++ b/exp_scenario/module/control/afk_kick.lua @@ -3,8 +3,19 @@ Kicks players when all players on the server are afk ]] local Async = require("modules/exp_util/async") +local Feature = require("modules/exp_scenario/features") +local Roles = require("modules/exp_roles") local Storage = require("modules/exp_util/storage") -local config = require("modules.exp_legacy.config.afk_kick") +local feature, config = Feature.register("afk_kick", { + admin_as_active = true, -- When true admins will be treated as active regardless of afk time + trust_as_active = true, -- When true trusted players (by playtime) will be treated as active regardless of afk time + active_role_id = Feature.optional("number"), -- Players with this role or higher are treated as active regardless of afk time + afk_minutes = 10, -- The time in minutes that must pass for a player to be considered afk + kick_minutes = 30, -- The time in minutes that must pass without any active players for all players to be kicked + trust_minutes = 600, -- The time in minutes that a player must be online for to count as trusted +}) + +local ticks_per_minute = 3600 --- @type { last_active: number } local script_data = { last_active = 0 } @@ -15,17 +26,25 @@ end) --- Kicks an afk player, used to add a delay so the gui has time to appear local afk_kick_player_async = Async.register(function(player) - if game.tick - script_data.last_active < config.kick_time then return end + if game.tick - script_data.last_active < config.kick_minutes * ticks_per_minute then return end game.kick_player(player, "AFK while no active players on the server") end) +--- Check if a player has a role which counts as active regardless of afk time +--- @param player LuaPlayer +--- @return boolean +local function is_active_role(player) + local role = config.active_role_id and Roles.get_role(config.active_role_id) + return role ~= nil and not Roles.get_player_highest_role(player):is_lower_than(role) +end + --- Check if there is an active player local function has_active_player() for _, player in ipairs(game.connected_players) do - if player.afk_time < config.afk_time + if player.afk_time < config.afk_minutes * ticks_per_minute or config.admin_as_active and player.admin - or config.trust_as_active and player.online_time > config.trust_time - or config.custom_active_check and config.custom_active_check(player) then + or config.trust_as_active and player.online_time > config.trust_minutes * ticks_per_minute + or is_active_role(player) then script_data.last_active = game.tick return true end @@ -40,7 +59,7 @@ local function check_afk_players() if has_active_player() then return end -- Check if players should be kicked - if game.tick - script_data.last_active < config.kick_time then return end + if game.tick - script_data.last_active < config.kick_minutes * ticks_per_minute then return end -- Kick time exceeded, kick all players for _, player in ipairs(game.connected_players) do @@ -73,12 +92,12 @@ end local e = defines.events -return { +return feature:guard{ events = { [e.on_player_joined_game] = on_player_joined_game, }, on_nth_tick = { - [config.update_time] = check_afk_players, + [60 * 60] = check_afk_players, }, has_active_player = has_active_player, } diff --git a/exp_scenario/module/control/bonus.lua b/exp_scenario/module/control/bonus.lua index 8d0a192d..262d192b 100644 --- a/exp_scenario/module/control/bonus.lua +++ b/exp_scenario/module/control/bonus.lua @@ -1,42 +1,70 @@ ---[[ Control - Bonus -Various bonus related event handlers - -TODO Refactor this fully, this is temp to get it out of the player bonus gui file -]] - -local Roles = require("modules/exp_roles") -local config = require("modules/exp_legacy/config/bonus") - ---- @param event EventData.on_force_created -local function apply_force_bonus(event) - local force = event.force - for k, v in pairs(config.force_bonus) do - force[k] = math.floor(v.max_value / 2) - end -end - ---- @param event EventData.on_surface_created -local function apply_surface_bonus(event) - local surface = assert(game.get_surface(event.surface_index)) - for k, v in pairs(config.surface_bonus) do - surface[k] = math.floor(v.max_value / 2) - end -end - ---- @param event EventData.on_player_died -local function fast_respawn(event) - local player = assert(game.get_player(event.player_index)) - if Roles.player_has_permission(player, "exp_scenario.player.instant_respawn") then - player.ticks_to_respawn = 120 - end -end - -local e = defines.events - -return { - events = { - [e.on_force_created] = apply_force_bonus, - [e.on_surface_created] = apply_surface_bonus, - [e.on_player_died] = fast_respawn, - } -} +--[[ Control - Bonus +Various bonus related event handlers + +TODO Refactor this fully, this is temp to get it out of the player bonus gui file +]] + +local Roles = require("modules/exp_roles") + +--- Force bonuses are started at half their max value +local force_bonus = { + ["worker_robots_battery_modifier"] = { + max_value = 1, + value_step = 1, + scale = 1, + cost = 1, + is_percentage = false, + }, + ["worker_robots_storage_bonus"] = { + max_value = 1, + value_step = 1, + scale = 1, + cost = 1, + is_percentage = false, + }, + ["following_robots_lifetime_modifier"] = { + max_value = 1, + value_step = 1, + scale = 1, + cost = 1, + is_percentage = false, + }, + +} + +--- Surface bonuses are started at half their max value +local surface_bonus = {} + +--- @param event EventData.on_force_created +local function apply_force_bonus(event) + local force = event.force + for k, v in pairs(force_bonus) do + force[k] = math.floor(v.max_value / 2) + end +end + +--- @param event EventData.on_surface_created +local function apply_surface_bonus(event) + local surface = assert(game.get_surface(event.surface_index)) + for k, v in pairs(surface_bonus) do + surface[k] = math.floor(v.max_value / 2) + end +end + +--- @param event EventData.on_player_died +local function fast_respawn(event) + local player = assert(game.get_player(event.player_index)) + if Roles.player_has_permission(player, "exp_scenario.player.instant_respawn") then + player.ticks_to_respawn = 120 + end +end + +local e = defines.events + +return { + events = { + [e.on_force_created] = apply_force_bonus, + [e.on_surface_created] = apply_surface_bonus, + [e.on_player_died] = fast_respawn, + } +} diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index 4e60ca40..1e1b6348 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -2,10 +2,10 @@ Adds auto replies to chat messages, as well as chat commands ]] +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.chat_reply") -local prefix = config.command_prefix -local prefix_len = string.len(prefix) +local feature = require("modules/exp_scenario/config/chat_auto_reply") +local config = feature.config local find = string.find local sub = string.sub @@ -16,11 +16,12 @@ local function on_console_chat(event) if not event.player_index then return end local player = assert(game.get_player(event.player_index)) local message = event.message:lower():gsub("%s+", "") + local prefix = config.command_prefix + local prefix_len = string.len(prefix) -- Check if the player can chat commands local commands_allowed = true - if config.command_admin_only and not player.admin then commands_allowed = false end - if config.command_permission and not Roles.player_has_permission(player, config.command_permission) then commands_allowed = false end + if not Roles.player_has_permission(player, "exp_scenario.chat.commands") then commands_allowed = false end -- Check if a key word appears in the message for key_word, reply in pairs(config.messages) do @@ -56,8 +57,8 @@ end local e = defines.events -return { +return feature:guard{ events = { [e.on_console_chat] = on_console_chat, - } + }, } diff --git a/exp_scenario/module/control/chat_popup.lua b/exp_scenario/module/control/chat_popup.lua deleted file mode 100644 index a858acf3..00000000 --- a/exp_scenario/module/control/chat_popup.lua +++ /dev/null @@ -1,48 +0,0 @@ ---[[-- Control - Chat Popup -Creates flying text entities when a player sends a message in chat -]] - -local FlyingText = require("modules/exp_util/flying_text") -local config = require("modules.exp_legacy.config.popup_messages") - -local lower = string.lower -local find = string.find - ---- Create a chat bubble when a player types a message ---- @param event EventData.on_console_chat -local function on_console_chat(event) - if not event.player_index then return end - local player = assert(game.get_player(event.player_index)) - local name = player.name - - -- Sends the message as text above them - if config.show_player_messages then - FlyingText.create_as_player{ - target_player = player, - text = { "exp_chat-popup.flying-text-message", name, event.message }, - } - end - - if not config.show_player_mentions then return end - - -- Loops over online players to see if they name is included - local search_string = lower(event.message) - for _, mentioned_player in ipairs(game.connected_players) do - if mentioned_player.index ~= player.index then - if find(search_string, lower(mentioned_player.name), 1, true) then - FlyingText.create_as_player{ - target_player = mentioned_player, - text = { "exp_chat-popup.flying-text-ping", name }, - } - end - end - end -end - -local e = defines.events - -return { - events = { - [e.on_console_chat] = on_console_chat, - } -} diff --git a/exp_scenario/module/control/custom_start.lua b/exp_scenario/module/control/custom_start.lua index 62d59a1a..5537fd22 100644 --- a/exp_scenario/module/control/custom_start.lua +++ b/exp_scenario/module/control/custom_start.lua @@ -2,9 +2,20 @@ Changes the starting script and the items given on first join depending on factory production levels ]] -local config = require("modules.exp_legacy.config.advanced_start") +local Feature = require("modules/exp_scenario/features") +local starting_items = require("modules/exp_scenario/config/starting_items") local floor = math.floor +--- Applied when the map is created +local feature, config = Feature.register("custom_start", { + skip_intro = true, -- skips the intro given in the default factorio free play scenario + skip_victory = true, -- will skip the victory screen when a rocket is launched + friendly_fire = false, -- weather players will be able to attack each other on the same force + disable_crashsite = true, -- weather to disable creation of the crashsite + enemy_expansion = false, -- a catch all for in case the map settings file fails to load + chart_radius = 320, -- the number of tiles that will be charted when the map starts +}) + --- Give a player their starting items --- @param player LuaPlayer local function give_starting_items(player) @@ -12,7 +23,7 @@ local function give_starting_items(player) local get_input_count = get_prod_stats.get_input_count local insert_param = { name = "", count = 0 } local insert = player.insert - for item_name, insert_amount in pairs(config.items) do + for item_name, insert_amount in pairs(starting_items) do insert_param.name = item_name if type(insert_amount) == "function" then local count = insert_amount(get_input_count(item_name), get_input_count, player) @@ -55,7 +66,7 @@ end local e = defines.events -return { +return feature:guard{ on_init = on_init, events = { [e.on_player_created] = on_player_created, diff --git a/exp_scenario/module/control/damage_popups.lua b/exp_scenario/module/control/damage_popups.lua deleted file mode 100644 index 803f95b6..00000000 --- a/exp_scenario/module/control/damage_popups.lua +++ /dev/null @@ -1,51 +0,0 @@ ---[[-- Control - Damage PopUps -Displays the amount of dmg that is done by players to entities; -also shows player health when a player is attacked -]] - -local FlyingText = require("modules/exp_util/flying_text") -local config = require("modules.exp_legacy.config.popup_messages") - -local random = math.random -local floor = math.floor -local max = math.max - ---- Called when entity entity is damaged including the player character ---- @param event EventData.on_entity_damaged -local function on_entity_damaged(event) - local message - local cause = event.cause - local entity = event.entity - - -- Check which message to display - if config.show_player_health and entity.name == "character" then - message = { "exp_damage-popup.flying-text-health", floor(entity.health) } - elseif config.show_player_damage and entity.name ~= "character" and cause and cause.name == "character" then - message = { "exp_damage-popup.flying-text-damage", floor(event.original_damage_amount) } - end - - -- Outputs the message as floating text - if message then - local entity_position = entity.position - local entity_radius = max(1, entity.get_radius()) - local offset = (random() - 0.5) * entity_radius * config.damage_location_variance - local position = { x = entity_position.x + offset, y = entity_position.y - entity_radius } - - local health_percentage = assert(entity.get_health_ratio()) - local color = { r = 1 - health_percentage, g = health_percentage, b = 0 } - - FlyingText.create{ - text = message, - position = position, - color = color, - } - end -end - -local e = defines.events - -return { - events = { - [e.on_entity_damaged] = on_entity_damaged, - }, -} diff --git a/exp_scenario/module/control/death_markers.lua b/exp_scenario/module/control/death_markers.lua index 24a74f30..e293a080 100644 --- a/exp_scenario/module/control/death_markers.lua +++ b/exp_scenario/module/control/death_markers.lua @@ -3,8 +3,18 @@ Makes markers on the map where places have died and reclaims items if not recove ]] local ExpUtil = require("modules/exp_util") +local Feature = require("modules/exp_scenario/features") local Storage = require("modules/exp_util/storage") -local config = require("modules.exp_legacy.config.death_logger") + +local feature, config = Feature.register("death_markers", { + collect_corpses = true, -- enables items being returned to the spawn point in chests upon corpse expiring + show_map_markers = true, -- shows markers on the map where bodies are + clean_map_markers = false, -- removes the map marker once the body is gone + include_time_of_death = true, -- weather to include the time of death on the map marker + map_icon = nil, -- the icon that the map marker shows; nil means no icon; format as a SingleID + show_light_at_corpse = true, -- if a light should be rendered at the corpse + show_line_to_corpse = true, -- if a line should be rendered from you to your corpse +}) local map_tag_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true } @@ -40,6 +50,7 @@ end --- Checks that all map tags are present and valid, creating any that are missing local function check_map_tags() + if not config.show_map_markers then return end for _, corpse_data in pairs(character_corpses) do if not corpse_data.tag or not corpse_data.tag.valid then create_map_tag(corpse_data) @@ -98,6 +109,7 @@ end --- Draw lines to the player corpse --- @param event EventData.on_player_respawned local function on_player_respawned(event) + if not config.show_line_to_corpse then return end local index = event.player_index local player = assert(game.get_player(index)) for _, corpse_data in pairs(character_corpses) do @@ -122,6 +134,7 @@ end --- Collect all items from expired character corpses --- @param event EventData.on_character_corpse_expired local function on_character_corpse_expired(event) + if not config.collect_corpses then return end local corpse = event.corpse local inventory = assert(corpse.get_inventory(defines.inventory.character_corpse)) ExpUtil.transfer_inventory_to_surface{ @@ -132,26 +145,16 @@ local function on_character_corpse_expired(event) } end -local on_nth_tick = {} -if config.show_map_markers then - on_nth_tick[config.period_check_map_tags] = check_map_tags -end - local e = defines.events -local events = { - [e.on_player_died] = on_player_died, - [e.on_object_destroyed] = on_object_destroyed, -} -if config.show_line_to_corpse then - events[e.on_player_respawned] = on_player_respawned -end - -if config.collect_corpses then - events[e.on_character_corpse_expired] = on_character_corpse_expired -end - -return { - on_nth_tick = on_nth_tick, - events = events, +return feature:guard{ + on_nth_tick = { + [60 * 60 * 5] = check_map_tags, + }, + events = { + [e.on_player_died] = on_player_died, + [e.on_object_destroyed] = on_object_destroyed, + [e.on_player_respawned] = on_player_respawned, + [e.on_character_corpse_expired] = on_character_corpse_expired, + }, } diff --git a/exp_scenario/module/control/deconstruction_log.lua b/exp_scenario/module/control/deconstruction_log.lua index 325bb3f8..62990ce6 100644 --- a/exp_scenario/module/control/deconstruction_log.lua +++ b/exp_scenario/module/control/deconstruction_log.lua @@ -5,7 +5,16 @@ Log certain actions into a file when events are triggered local ExpUtil = require("modules/exp_util") local Storage = require("modules/exp_util/storage") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.deconlog") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("deconstruction_log", { + decon_area = true, -- log when an area is deconstructed + built_entity = true, -- log when an entity is built + mined_entity = true, -- log when an entity is mined + fired_rocket = true, -- log when a rocket is fired + fired_explosive_rocket = true, -- log when an explosive rocket is fired + fired_nuke = true, -- log when a nuke is fired +}) local seconds_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true, seconds = true } local format_number = require("util").format_number @@ -90,6 +99,7 @@ end --- Log when an area is deconstructed --- @param event EventData.on_player_deconstructed_area local function on_player_deconstructed_area(event) + if not config.decon_area then return end local player = get_log_player(event) if not player then return end @@ -118,6 +128,7 @@ end --- Log when an entity is built --- @param event EventData.on_built_entity local function on_built_entity(event) + if not config.built_entity then return end local player = get_log_player(event) if not player then return end add_log_line(player, "built_entity", format_entity(event.entity)) @@ -126,16 +137,17 @@ end --- Log when an entity is mined --- @param event EventData.on_player_mined_entity local function on_player_mined_entity(event) + if not config.mined_entity then return end local player = get_log_player(event) if not player then return end add_log_line(player, "mined_entity", format_entity(event.entity)) end ---- Ammo which is logged when fired -local logged_ammo = { - ["rocket"] = config.fired_rocket, - ["explosive-rocket"] = config.fired_explosive_rocket, - ["atomic-bomb"] = config.fired_nuke, +--- The setting which decides if a shot of an ammo is logged +local ammo_settings = { + ["rocket"] = "fired_rocket", + ["explosive-rocket"] = "fired_explosive_rocket", + ["atomic-bomb"] = "fired_nuke", } --- @class ExpScenario_DeconstructionLog.AmmoSlot @@ -186,7 +198,7 @@ local function on_player_ammo_inventory_changed(event) fired = previous.name end - if fired and logged_ammo[fired] then + if fired and config[ammo_settings[fired]] then add_log_line(player, "shot-" .. fired, format_position(player.physical_position), format_position(player.shooting_state.position)) end end @@ -206,29 +218,16 @@ local function on_player_left_game(event) end local e = defines.events -local events = { - [e.on_multiplayer_init] = clear_log, -} -if config.decon_area then - events[e.on_player_deconstructed_area] = on_player_deconstructed_area -end - -if config.built_entity then - events[e.on_built_entity] = on_built_entity -end - -if config.mined_entity then - events[e.on_player_mined_entity] = on_player_mined_entity -end - -if config.fired_rocket or config.fired_explosive_rocket or config.fired_nuke then - events[e.on_player_ammo_inventory_changed] = on_player_ammo_inventory_changed - events[e.on_player_joined_game] = on_player_character_changed - events[e.on_player_respawned] = on_player_character_changed - events[e.on_player_left_game] = on_player_left_game -end - -return { - events = events, +return feature:guard{ + events = { + [e.on_multiplayer_init] = clear_log, + [e.on_player_deconstructed_area] = on_player_deconstructed_area, + [e.on_built_entity] = on_built_entity, + [e.on_player_mined_entity] = on_player_mined_entity, + [e.on_player_ammo_inventory_changed] = on_player_ammo_inventory_changed, + [e.on_player_joined_game] = on_player_character_changed, + [e.on_player_respawned] = on_player_character_changed, + [e.on_player_left_game] = on_player_left_game, + }, } diff --git a/exp_scenario/module/control/degrading_tiles.lua b/exp_scenario/module/control/degrading_tiles.lua index dc3a1b11..ab9c4e47 100644 --- a/exp_scenario/module/control/degrading_tiles.lua +++ b/exp_scenario/module/control/degrading_tiles.lua @@ -2,13 +2,108 @@ When a player walks around the tiles under them will degrade over time, the same is true when entites are built ]] -local config = require("modules.exp_legacy.config.scorched_earth") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("degrading_tiles", { + weakness_value = 70, -- lower value will make tiles more likely to degrade + entities = Feature.set{ -- entities which degrade the tiles under them when they are placed + "stone-furnace", "steel-furnace", "electric-furnace", "assembling-machine-1", "assembling-machine-2", "assembling-machine-3", + "beacon", "centrifuge", "chemical-plant", "oil-refinery", "storage-tank", "nuclear-reactor", + "steam-engine", "steam-turbine", "boiler", "heat-exchanger", "stone-wall", "gate", + "gun-turret", "laser-turret", "flamethrower-turret", "radar", "lab", "big-electric-pole", + "substation", "rocket-silo", "pumpjack", "electric-mining-drill", "roboport", "accumulator", + }, +}) local random = math.random +--- How "strong" a tile is, bigger number means less likely to degrade +local strengths = { + -- debug: /interface require('modules.addons.worn-paths')(player.name,true) + -- note: tiles are effected by the tiles around them, so player paths will not degrade as fast when made wider + -- note: values are relative to the tile with the highest value, recommended to keep highest tile as a "nice" number + -- note: tiles not in list will never degrade under any conditions (which is why some are omitted such as water) + ["refined-concrete"] = 100, + ["refined-hazard-concrete-left"] = 100, + ["refined-hazard-concrete-right"] = 100, + ["concrete"] = 90, + ["hazard-concrete-left"] = 90, + ["hazard-concrete-right"] = 90, + ["stone-path"] = 80, + ["red-desert-0"] = 80, + ["dry-dirt"] = 50, + -- grass four (main grass tiles) + ["grass-1"] = 50, + ["grass-2"] = 40, + ["grass-3"] = 30, + ["grass-4"] = 25, + -- red three (main red tiles) + ["red-desert-1"] = 40, + ["red-desert-2"] = 30, + ["red-desert-3"] = 25, + -- sand three (main sand tiles) + ["sand-1"] = 40, + ["sand-2"] = 30, + ["sand-3"] = 25, + -- dirt 3 (main dirt tiles) + ["dirt-1"] = 40, + ["dirt-2"] = 30, + ["dirt-3"] = 25, + -- last three/four (all sets of three merge here) + ["dirt-4"] = 25, + ["dirt-5"] = 30, + ["dirt-6"] = 40, + -- ["dirt-7"]=0, -- last tile, nothing to degrade to + -- land fill chain + -- ["landfill"]=50, + -- ["water-shallow"]=90, + -- ["water-mud"]=0, -- last tile, nothing to degrade to + +} + +--- When a tile degrades it will turn into the next tile given here +local degrade_order = { + ["refined-concrete"] = "concrete", + ["refined-hazard-concrete-left"] = "hazard-concrete-left", + ["refined-hazard-concrete-right"] = "hazard-concrete-right", + ["concrete"] = "stone-path", + ["hazard-concrete-left"] = "stone-path", + ["hazard-concrete-right"] = "stone-path", + ["stone-path"] = "dry-dirt", + ["red-desert-0"] = "dry-dirt", + ["dry-dirt"] = "dirt-4", + -- grass four (main grass tiles) + ["grass-1"] = "grass-2", + ["grass-2"] = "grass-3", + ["grass-3"] = "grass-4", + ["grass-4"] = "dirt-4", + -- red three (main red tiles) + ["red-desert-1"] = "red-desert-2", + ["red-desert-2"] = "red-desert-3", + ["red-desert-3"] = "dirt-4", + -- sand three (main sand tiles) + ["sand-1"] = "sand-2", + ["sand-2"] = "sand-3", + ["sand-3"] = "dirt-4", + -- dirt 3 (main dirt tiles) + ["dirt-1"] = "dirt-2", + ["dirt-2"] = "dirt-3", + ["dirt-3"] = "dirt-4", + -- last three/four (all sets of three merge here) + ["dirt-4"] = "dirt-5", + ["dirt-5"] = "dirt-6", + ["dirt-6"] = "dirt-7", + -- ["dirt-7"]=0, -- last tile, nothing to degrade to + -- land fill chain + -- ["landfill"]='grass-2', -- 'water-shallow' + -- ["water-shallow"]='water-mud', + -- ["water-mud"]=0, -- last tile, nothing to degrade to + +} + --- Get the max tile strength local max_strength = 0 -for _, strength in pairs(config.strengths) do +for _, strength in pairs(strengths) do if strength > max_strength then max_strength = strength end @@ -20,7 +115,7 @@ end local function degrade_tile(surface, position) local tile = surface.get_tile(position.x, position.y) local tile_name = tile.name - local degrade_tile_name = config.degrade_order[tile_name] + local degrade_tile_name = degrade_order[tile_name] if not degrade_tile_name then return end surface.set_tiles{ { name = degrade_tile_name, position = position } } end @@ -39,7 +134,7 @@ local function degrade_entity(entity) for y = left_top.y, right_bottom.y do local tile = surface.get_tile(x, y) local tile_name = tile.name - local degrade_tile_name = config.degrade_order[tile_name] + local degrade_tile_name = degrade_order[tile_name] if degrade_tile_name then tiles[#tiles + 1] = { name = degrade_tile_name, position = { x, y } } end @@ -63,14 +158,14 @@ end local function get_tile_strength(surface, position) local tile = surface.get_tile(position.x, position.y) local tile_name = tile.name - local strength = config.strengths[tile_name] + local strength = strengths[tile_name] if not strength then return end for x = position.x - 1, position.x + 1 do for y = position.y - 1, position.y + 1 do local check_tile = surface.get_tile(x, y) local check_tile_name = check_tile.name - local check_strength = config.strengths[check_tile_name] or 0 + local check_strength = strengths[check_tile_name] or 0 strength = strength + check_strength end end @@ -108,7 +203,7 @@ end local e = defines.events -return { +return feature:guard{ events = { [e.on_player_changed_position] = on_player_changed_position, [e.on_robot_built_entity] = on_built_entity, diff --git a/exp_scenario/module/control/discord_alerts.lua b/exp_scenario/module/control/discord_alerts.lua index 6b700341..064af60a 100644 --- a/exp_scenario/module/control/discord_alerts.lua +++ b/exp_scenario/module/control/discord_alerts.lua @@ -4,7 +4,34 @@ Sends alert messages to our discord server when certain events are triggered local ExpUtil = require("modules/exp_util") local Colors = require("modules/exp_util/include/color") -local config = require("modules.exp_legacy.config.discord_alerts") +local Feature = require("modules/exp_scenario/features") +local EntityProtection = require("modules/exp_scenario/control/protection") +local Jail = require("modules/exp_scenario/control/jail") + +local feature, config = Feature.register("discord_alerts", { + show_playtime = true, -- Add the playtime of players to alerts + entity_protection = true, -- Alert when a player repeatedly removes protected entities + player_bans = true, -- Alert when a player is banned or unbanned + player_mutes = true, -- Alert when a player is muted or unmuted + player_kicks = true, -- Alert when a player is kicked + player_promotes = false, -- Alert when a player is promoted or demoted + player_jail = true, -- Alert when a player is jailed or unjailed + logged_commands = Feature.set{ -- Alert when a player uses one of these commands + "config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat", + }, +}) + +--- Wrap an event handler so it only runs while the alert is enabled +--- @param setting string +--- @param handler fun(event: table) +--- @return fun(event: table) +local function when(setting, handler) + return function(event) + if config[setting] then + return handler(event) + end + end +end local format_string = string.format local write_json = ExpUtil.write_json @@ -80,172 +107,158 @@ local function emit_event(opts) end --- Repeated protected entity mining -if config.entity_protection then - local EntityProtection = require("modules/exp_scenario/control/protection") - events[EntityProtection.on_repeat_violation] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Entity Protection", - description = "A player removed protected entities", - color = Colors.yellow, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "Entity", inline = true, value = event.entity.name }, - { name = "Location", value = format_string("X %.1f Y %.1f", event.entity.position.x, event.entity.position.y) }, - }, - } - end -end +events[EntityProtection.on_repeat_violation] = when("entity_protection", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Entity Protection", + description = "A player removed protected entities", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "Entity", inline = true, value = event.entity.name }, + { name = "Location", value = format_string("X %.1f Y %.1f", event.entity.position.x, event.entity.position.y) }, + }, + } +end) --- When a player is jailed or unjailed -if config.player_jail then - local Jail = require("modules/exp_scenario/control/jail") - events[Jail.on_player_jailed] = function(event) - local player_name, by_player_name = get_player_name(event) +events[Jail.on_player_jailed] = when("player_jail", function(event) + local player_name, by_player_name = get_player_name(event) + emit_event{ + title = "Jail", + description = "A player has been jailed", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Reason", value = event.reason }, + }, + } +end) +events[Jail.on_player_unjailed] = when("player_jail", function(event) + local player_name, by_player_name = get_player_name(event) + emit_event{ + title = "Unjail", + description = "A player has been unjailed", + color = Colors.green, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "By", inline = true, value = append_playtime(by_player_name) }, + }, + } +end) + +--- Ban and unban +--- @param event EventData.on_player_banned +events[e.on_player_banned] = when("player_bans", function(event) + if event.by_player then + local by_player = game.players[event.by_player] emit_event{ - title = "Jail", - description = "A player has been jailed", - color = Colors.yellow, + title = "Banned", + description = "A player has been banned", + color = Colors.red, fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Player", inline = true, value = append_playtime(event.player_name) }, + { name = "By", inline = true, value = append_playtime(by_player.name) }, { name = "Reason", value = event.reason }, }, } end - events[Jail.on_player_unjailed] = function(event) - local player_name, by_player_name = get_player_name(event) +end) +--- @param event EventData.on_player_unbanned +events[e.on_player_unbanned] = when("player_bans", function(event) + if event.by_player then + local by_player = game.players[event.by_player] emit_event{ - title = "Unjail", - description = "A player has been unjailed", + title = "Un-Banned", + description = "A player has been un-banned", color = Colors.green, fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Player", inline = true, value = append_playtime(event.player_name) }, + { name = "By", inline = true, value = append_playtime(by_player.name) }, + { name = "Reason", value = event.reason }, }, } end -end - ---- Ban and unban -if config.player_bans then - --- @param event EventData.on_player_banned - events[e.on_player_banned] = function(event) - if event.by_player then - local by_player = game.players[event.by_player] - emit_event{ - title = "Banned", - description = "A player has been banned", - color = Colors.red, - fields = { - { name = "Player", inline = true, value = append_playtime(event.player_name) }, - { name = "By", inline = true, value = append_playtime(by_player.name) }, - { name = "Reason", value = event.reason }, - }, - } - end - end - --- @param event EventData.on_player_unbanned - events[e.on_player_unbanned] = function(event) - if event.by_player then - local by_player = game.players[event.by_player] - emit_event{ - title = "Un-Banned", - description = "A player has been un-banned", - color = Colors.green, - fields = { - { name = "Player", inline = true, value = append_playtime(event.player_name) }, - { name = "By", inline = true, value = append_playtime(by_player.name) }, - { name = "Reason", value = event.reason }, - }, - } - end - end -end +end) --- Mute and unmute -if config.player_mutes then - --- @param event EventData.on_player_muted - events[e.on_player_muted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Muted", - description = "A player has been muted", - color = Colors.yellow, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end - --- @param event EventData.on_player_unmuted - events[e.on_player_unmuted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Un-Muted", - description = "A player has been un-muted", - color = Colors.green, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end -end +--- @param event EventData.on_player_muted +events[e.on_player_muted] = when("player_mutes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Muted", + description = "A player has been muted", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) +--- @param event EventData.on_player_unmuted +events[e.on_player_unmuted] = when("player_mutes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Un-Muted", + description = "A player has been un-muted", + color = Colors.green, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) --- Kick -if config.player_kicks then - --- @param event EventData.on_player_kicked - events[e.on_player_kicked] = function(event) - if event.by_player then - local player_name = get_player_name(event) - local by_player = game.players[event.by_player] - emit_event{ - title = "Kick", - description = "A player has been kicked", - color = Colors.orange, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(by_player.name) }, - { name = "Reason", value = event.reason }, - }, - } - end +--- @param event EventData.on_player_kicked +events[e.on_player_kicked] = when("player_kicks", function(event) + if event.by_player then + local player_name = get_player_name(event) + local by_player = game.players[event.by_player] + emit_event{ + title = "Kick", + description = "A player has been kicked", + color = Colors.orange, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + { name = "By", inline = true, value = append_playtime(by_player.name) }, + { name = "Reason", value = event.reason }, + }, + } end -end +end) --- Promote and demote -if config.player_promotes then - --- @param event EventData.on_player_promoted - events[e.on_player_promoted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Promote", - description = "A player has been promoted", - color = Colors.green, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end - --- @param event EventData.on_player_demoted - events[e.on_player_demoted] = function(event) - local player_name = get_player_name(event) - emit_event{ - title = "Demote", - description = "A player has been demoted", - color = Colors.yellow, - fields = { - { name = "Player", inline = true, value = append_playtime(player_name) }, - }, - } - end -end +--- @param event EventData.on_player_promoted +events[e.on_player_promoted] = when("player_promotes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Promote", + description = "A player has been promoted", + color = Colors.green, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) +--- @param event EventData.on_player_demoted +events[e.on_player_demoted] = when("player_promotes", function(event) + local player_name = get_player_name(event) + emit_event{ + title = "Demote", + description = "A player has been demoted", + color = Colors.yellow, + fields = { + { name = "Player", inline = true, value = append_playtime(player_name) }, + }, + } +end) --- @param event EventData.on_console_command events[e.on_console_command] = function(event) if event.player_index then local player_name = get_player_name(event) - if config[event.command] then + if config.logged_commands[event.command] then emit_event{ title = event.command:gsub("^%l", string.upper), description = "/" .. event.command .. " was used", @@ -259,6 +272,6 @@ events[e.on_console_command] = function(event) end end -return { +return feature:guard{ events = events, } diff --git a/exp_scenario/module/control/extra_logging.lua b/exp_scenario/module/control/extra_logging.lua index 07055245..b1a91bc2 100644 --- a/exp_scenario/module/control/extra_logging.lua +++ b/exp_scenario/module/control/extra_logging.lua @@ -2,8 +2,39 @@ Log some extra events to a separate file ]] -local config = require("modules.exp_legacy.config.logging") -local config_res = require("modules.exp_legacy.config.research") +local Feature = require("modules/exp_scenario/features") +local research_data = require("modules/exp_scenario/config/research_data") + +local feature, config = Feature.register("extra_logging", { + file_name = "log/logging.log", -- the file the lines are written to + rocket_launch_display_rate = 500, -- after the counts below, log every this many rockets +}) + +--- Rocket counts which are always logged +local rocket_launch_display = { + [1] = true, + [2] = true, + [5] = true, + [10] = true, + [20] = true, + [50] = true, + [100] = true, + [200] = true, +} + +local disconnect_reason = { + [defines.disconnect_reason.quit] = "left the game", + [defines.disconnect_reason.dropped] = "was dropped from the game", + [defines.disconnect_reason.reconnect] = "is reconnecting", + [defines.disconnect_reason.wrong_input] = "was having a wrong input", + [defines.disconnect_reason.desync_limit_reached] = "had desync limit reached", + [defines.disconnect_reason.cannot_keep_up] = "cannot keep up", + [defines.disconnect_reason.afk] = "was afk", + [defines.disconnect_reason.kicked] = "was kicked", + [defines.disconnect_reason.kicked_and_deleted] = "was kicked and deleted", + [defines.disconnect_reason.banned] = "was banned", + [defines.disconnect_reason.switching_servers] = "is switching servers", +} local concat = table.concat local write_file = helpers.write_file @@ -26,7 +57,7 @@ local function on_cargo_pod_finished_ascending(event) local force = event.cargo_pod.force --[[@as LuaForce]] if force.rockets_launched >= config.rocket_launch_display_rate and force.rockets_launched % config.rocket_launch_display_rate == 0 then add_log_line("[ROCKET]", force.rockets_launched, "rockets launched") - elseif config.rocket_launch_display[force.rockets_launched] then + elseif rocket_launch_display[force.rockets_launched] then add_log_line("[ROCKET]", force.rockets_launched, "rockets launched") end end @@ -53,7 +84,7 @@ local function on_research_finished(event) return end - local inf_research_level = config_res.inf_res[config_res.mod_set][event.research.name] + local inf_research_level = research_data.inf_res[research_data.mod_set][event.research.name] if inf_research_level and event.research.level >= inf_research_level then add_log_line_locale{ "", "[RES]", event.research.prototype.localised_name, " at level ", event.research.level - 1, " has been researched\n" } else @@ -70,17 +101,17 @@ end --- @param event EventData.on_player_left_game local function on_player_left_game(event) local player = assert(game.get_player(event.player_index)) - add_log_line("[LEAVE]", player.name, config.disconnect_reason[event.reason]) + add_log_line("[LEAVE]", player.name, disconnect_reason[event.reason]) end local e = defines.events -return { +return feature:guard{ events = { [e.on_cargo_pod_finished_ascending] = on_cargo_pod_finished_ascending, [e.on_pre_player_died] = on_pre_player_died, [e.on_research_finished] = on_research_finished, [e.on_player_joined_game] = on_player_joined_game, [e.on_player_left_game] = on_player_left_game, - } + }, } diff --git a/exp_scenario/module/control/features.lua b/exp_scenario/module/control/features.lua new file mode 100644 index 00000000..738ddc38 --- /dev/null +++ b/exp_scenario/module/control/features.lua @@ -0,0 +1,21 @@ +--[[-- Control - Features +Refreshes the gui of every player when a feature is enabled or disabled, see features.lua +]] + +local Feature = require("modules/exp_scenario/features") +local Gui = require("modules/exp_gui") + +--- Toolbar buttons of disabled features are hidden, which also hides their left elements +--- @param event EventData.ExpScenario.on_config_updated +local function on_config_updated(event) + if event.path ~= "enabled" then return end + for _, player in pairs(game.connected_players) do + Gui._ensure_consistency{ player_index = player.index } + end +end + +return { + events = { + [Feature.events.on_config_updated] = on_config_updated, + }, +} diff --git a/exp_scenario/module/control/help_bubbles.lua b/exp_scenario/module/control/help_bubbles.lua index b37c143f..51f8a12f 100644 --- a/exp_scenario/module/control/help_bubbles.lua +++ b/exp_scenario/module/control/help_bubbles.lua @@ -4,7 +4,34 @@ Adds friendly biters that walk around and give helpful messages local Async = require("modules/exp_util/async") local Storage = require("modules/exp_util/storage") -local config = require("modules.exp_legacy.config.compilatron") +local Feature = require("modules/exp_scenario/features") +local config_server_detail = require("modules.exp_legacy.config.server_detail") + +local feature = Feature.register("help_bubbles") + +--- Ticks between message changes +local message_cycle = 60 * 15 + +local locations = { + ["Spawn"] = { + spawn_position = { x = 0, y = 0 }, + spawn_surface = "nauvis", + entity_name = "small-biter", + messages = { + { "info.website", config_server_detail["website"] }, + { "info.read-readme" }, + { "info.discord", config_server_detail["discord"] }, + { "info.softmod" }, + { "info.redmew" }, + { "info.custom-commands" }, + { "info.status", config_server_detail["status"] }, + { "info.lhd" }, + { "info.github", config_server_detail["github"] }, + { "info.patreon", config_server_detail["patreon"] }, + }, + } + +} --- @type table> local persistent_locations = {} @@ -48,7 +75,7 @@ local speech_bubble_task = task.previous_message = entity.surface.create_entity(speech_bubble_param) task.current_message_index = index + 1 - return Async.status.delay(config.message_cycle, task) + return Async.status.delay(message_cycle, task) end) --- Register an entity to start spawning speech bubbles @@ -66,7 +93,7 @@ end --- Check all persistent locations from the config are active local function check_persistent_locations() - for name, location in pairs(config.locations) do + for name, location in pairs(locations) do local task = persistent_locations[name] if task and not task.completed then goto continue @@ -92,9 +119,9 @@ local function check_persistent_locations() end end -return { +return feature:guard{ on_nth_tick = { - [config.message_cycle] = check_persistent_locations, + [message_cycle] = check_persistent_locations, }, register_entity = register_entity, } diff --git a/exp_scenario/module/control/inventory_clear.lua b/exp_scenario/module/control/inventory_clear.lua index cdbfd650..6a04a105 100644 --- a/exp_scenario/module/control/inventory_clear.lua +++ b/exp_scenario/module/control/inventory_clear.lua @@ -6,7 +6,15 @@ on_player_kicked - player_index ]] local ExpUtil = require("modules/exp_util") -local events = require("modules.exp_legacy.config.inventory_clear") +local Feature = require("modules/exp_scenario/features") + +local feature = Feature.register("inventory_clear") + +--- The events which clear the inventory of the player +local events = { + defines.events.on_player_banned, + defines.events.on_player_kicked, +} --- @param event { player_index: number } local function clear_items(event) @@ -26,6 +34,6 @@ for _, event_name in ipairs(events) do event_handlers[event_name] = clear_items end -return { +return feature:guard{ events = event_handlers, } diff --git a/exp_scenario/module/control/mine_depletion.lua b/exp_scenario/module/control/mine_depletion.lua index f8a6cb55..77efb618 100644 --- a/exp_scenario/module/control/mine_depletion.lua +++ b/exp_scenario/module/control/mine_depletion.lua @@ -3,7 +3,13 @@ Marks mining drills for deconstruction when resources deplete ]] local Async = require("modules/exp_util/async") -local config = require("modules.exp_legacy.config.miner") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("mine_depletion", { + fluid = true, -- When true, checks for for fluid pipes when removing miners + chest = true, -- When true, checks for for chest when removing miners + beacon = true, -- When true, checks for for beacon when removing miners +}) local floor = math.floor @@ -270,7 +276,7 @@ end local e = defines.events -return { +return feature:guard{ events = { [e.on_resource_depleted] = on_resource_depleted, }, diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index ba8f4320..e8638ce4 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -3,20 +3,32 @@ Disable new players from having certain items in their inventory, most commonly ]] local ExpUtil = require("modules/exp_util") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.nukeprotect") + +local feature, config = Feature.register("nuke_protection", { + banned_items = Feature.set{ "atomic-bomb" }, -- Items which are removed from the inventory of players without the bypass permission +}) + +--- The inventories which are checked and the events which trigger the check +local inventories = { + [defines.events.on_player_ammo_inventory_changed] = defines.inventory.character_ammo, + [defines.events.on_player_armor_inventory_changed] = defines.inventory.character_armor, + [defines.events.on_player_gun_inventory_changed] = defines.inventory.character_guns, + [defines.events.on_player_main_inventory_changed] = defines.inventory.character_main, +} --- Check all items in the given inventory --- @param player LuaPlayer --- @param type defines.inventory ---- @param banned_items string[] -local function check_items(player, type, banned_items) +local function check_items(player, type) -- If the player has perms to be ignored, then they should be - if config.ignore_permission and Roles.player_has_permission(player, config.ignore_permission) then return end - if config.ignore_admins and player.admin then return end + if Roles.player_has_permission(player, "exp_scenario.bypass.nuke_protection") then return end + local banned_items = config.banned_items local items = {} --- @type LuaItemStack[] - local inventory = assert(player.get_inventory(type)) + local inventory = player.get_inventory(type) + if not inventory then return end -- Check what items the player has for i = 1, #inventory do local item = inventory[i --[[@as uint]]] @@ -37,20 +49,16 @@ end --- Add event handlers for the different inventories local events = {} -for index, inventory in ipairs(config.inventories) do - if next(inventory.items) then - local assert_msg = "invalid event, no player index, index: " .. index - --- @param event { player_index: number } - events[inventory.event] = function(event) - local player_index = assert(event.player_index, assert_msg) - local player = assert(game.get_player(player_index)) - if player and player.valid then - check_items(player, inventory.inventory, inventory.items) - end +for event_id, inventory in pairs(inventories) do + --- @param event { player_index: number } + events[event_id] = function(event) + local player = assert(game.get_player(event.player_index)) + if player.valid then + check_items(player, inventory) end end end -return { +return feature:guard{ events = events, } diff --git a/exp_scenario/module/control/pollution_grading.lua b/exp_scenario/module/control/pollution_grading.lua index 8bd8f876..da480c0d 100644 --- a/exp_scenario/module/control/pollution_grading.lua +++ b/exp_scenario/module/control/pollution_grading.lua @@ -2,7 +2,16 @@ Makes pollution look much nice of the map, ie not one big red mess ]] -local config = require("modules.exp_legacy.config.pollution_grading") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("pollution_grading", { + reference_point = { -- where pollution is read from + x = 0, + y = 0, + }, + max_scalar = 0.5, -- the scale between true max and max + min_scalar = 0.17, -- the scale between the lowest max and min +}) local function check_surfaces() local max_reference = 0 @@ -20,8 +29,8 @@ local function check_surfaces() settings.min_to_show_per_chunk = min end -return { +return feature:guard{ on_nth_tick = { - [config.update_delay * 3600] = check_surfaces, - } + [15 * 3600] = check_surfaces, + }, } diff --git a/exp_scenario/module/control/popups.lua b/exp_scenario/module/control/popups.lua new file mode 100644 index 00000000..f25b5fba --- /dev/null +++ b/exp_scenario/module/control/popups.lua @@ -0,0 +1,92 @@ +--[[-- Control - Popups +Creates flying text for chat messages and for damage dealt to and by players +]] + +local FlyingText = require("modules/exp_util/flying_text") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("popups", { + show_player_messages = true, -- weather a message in chat will make a popup above them + show_player_mentions = true, -- weather a mentioned player will have a popup when mentioned in chat + show_player_damage = true, -- weather to show damage done by players + show_player_health = true, -- weather to show player health when attacked + damage_location_variance = 0.8, -- how close to the eade of an entity the popups will appear +}) + +local lower = string.lower +local find = string.find +local random = math.random +local floor = math.floor +local max = math.max + +--- Create a chat bubble when a player types a message +--- @param event EventData.on_console_chat +local function on_console_chat(event) + if not event.player_index then return end + local player = assert(game.get_player(event.player_index)) + local name = player.name + + -- Sends the message as text above them + if config.show_player_messages then + FlyingText.create_as_player{ + target_player = player, + text = { "exp_chat-popup.flying-text-message", name, event.message }, + } + end + + if not config.show_player_mentions then return end + + -- Loops over online players to see if they name is included + local search_string = lower(event.message) + for _, mentioned_player in ipairs(game.connected_players) do + if mentioned_player.index ~= player.index then + if find(search_string, lower(mentioned_player.name), 1, true) then + FlyingText.create_as_player{ + target_player = mentioned_player, + text = { "exp_chat-popup.flying-text-ping", name }, + } + end + end + end +end + +--- Called when entity entity is damaged including the player character +--- @param event EventData.on_entity_damaged +local function on_entity_damaged(event) + local message + local cause = event.cause + local entity = event.entity + + -- Check which message to display + if config.show_player_health and entity.name == "character" then + message = { "exp_damage-popup.flying-text-health", floor(entity.health) } + elseif config.show_player_damage and entity.name ~= "character" and cause and cause.name == "character" then + message = { "exp_damage-popup.flying-text-damage", floor(event.original_damage_amount) } + end + + -- Outputs the message as floating text + if message then + local entity_position = entity.position + local entity_radius = max(1, entity.get_radius()) + local offset = (random() - 0.5) * entity_radius * config.damage_location_variance + local position = { x = entity_position.x + offset, y = entity_position.y - entity_radius } + + local health_percentage = assert(entity.get_health_ratio()) + local color = { r = 1 - health_percentage, g = health_percentage, b = 0 } + + FlyingText.create{ + text = message, + position = position, + color = color, + } + end +end + +local e = defines.events + +return feature:guard{ + events = { + [e.on_console_chat] = on_console_chat, + [e.on_entity_damaged] = on_entity_damaged, + }, +} diff --git a/exp_scenario/module/control/protection.lua b/exp_scenario/module/control/protection.lua index 9839290f..324c9f79 100644 --- a/exp_scenario/module/control/protection.lua +++ b/exp_scenario/module/control/protection.lua @@ -3,8 +3,21 @@ Protects entities and areas from being mined by players other than the one who p ]] local Storage = require("modules/exp_util/storage") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.protection") + +local feature, config = Feature.register("protection", { + repeat_count = 5, -- Number of protected entities that must be removed within repeat_minutes in order to trigger repeated removal protection + repeat_minutes = 20, -- The length of time, in minutes, that protected removals will be remembered for + always_protected_names = Feature.set{}, -- Names of entities which are always protected + always_protected_types = Feature.set{ -- Types of entities which are always protected + "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", + }, + always_trigger_repeat_names = Feature.set{}, -- Names of entities which always trigger repeated removal protection + always_trigger_repeat_types = Feature.set{ -- Types of entities which always trigger repeated removal protection + "reactor", "fusion-reactor", "rocket-silo", + }, +}) local format_string = string.format local floor = math.floor @@ -17,12 +30,8 @@ local Protection = { --- Raised when a player mines protected entities repeatedly, or one which always counts as repeated --- @type EventData.ExpScenario_Protection.on_repeat_violation on_repeat_violation = script.generate_event_name(), - --- Names of entities which are always protected - --- @type string[] - protected_entity_names = config.always_protected_names, - --- Types of entities which are always protected - --- @type string[] - protected_entity_types = config.always_protected_types, + --- The config of the feature, read only + config = config, --- @package events = {}, --- @package @@ -36,33 +45,29 @@ local Protection = { --- @field last uint Tick of the last protected removal --- @field count number Protected removals since the last repeat violation ---- @param values string[] ---- @return table -local function to_set(values) - local set = {} - for _, value in ipairs(values) do - set[value] = true - end - return set -end +--- @class ExpScenario_Protection.Registration +--- @field surface_index uint +--- @field key string +--- @field entity LuaEntity -local always_protected_names = to_set(config.always_protected_names) -local always_protected_types = to_set(config.always_protected_types) -local always_trigger_repeat_names = to_set(config.always_trigger_repeat_names) -local always_trigger_repeat_types = to_set(config.always_trigger_repeat_types) - -local protected_entities = {} --- @type table> Keyed by surface index then entity key -local protected_areas = {} --- @type table> Keyed by surface index then area key -local repeats = {} --- @type table Keyed by player name +local protected_entities = {} --- @type table> By surface index then entity key +local protected_areas = {} --- @type table> By surface index then area key +local registrations = {} --- @type table By on_object_destroyed registration number +local repeats = {} --- @type table By player name Storage.register({ protected_entities = protected_entities, protected_areas = protected_areas, + registrations = registrations, repeats = repeats, }, function(tbl) protected_entities = tbl.protected_entities protected_areas = tbl.protected_areas + registrations = tbl.registrations repeats = tbl.repeats +end, function(tbl) + -- Saves from before entities were registered + tbl.registrations = tbl.registrations or {} end) --- Get the key an entity is stored under @@ -79,15 +84,18 @@ function Protection.get_area_key(area) return format_string("%i,%i", floor(area.left_top.x), floor(area.left_top.y)) end ---- Protect an entity +--- Protect an entity, it is forgotten when destroyed --- @param entity LuaEntity function Protection.add_entity(entity) - local entities = protected_entities[entity.surface.index] + local surface_index = entity.surface.index + local entities = protected_entities[surface_index] if not entities then entities = {} - protected_entities[entity.surface.index] = entities + protected_entities[surface_index] = entities end - entities[Protection.get_entity_key(entity)] = entity + local key = Protection.get_entity_key(entity) + entities[key] = entity + registrations[script.register_on_object_destroyed(entity)] = { surface_index = surface_index, key = key, entity = entity } end --- Remove the protection from an entity @@ -95,7 +103,11 @@ end function Protection.remove_entity(entity) local entities = protected_entities[entity.surface.index] if not entities then return end - entities[Protection.get_entity_key(entity)] = nil + local key = Protection.get_entity_key(entity) + if entities[key] == entity then + entities[key] = nil + registrations[script.register_on_object_destroyed(entity)] = nil + end end --- Get the protected entities on a surface, always protected entities are not included @@ -109,7 +121,7 @@ end --- @param entity LuaEntity --- @return boolean function Protection.is_entity_protected(entity) - if always_protected_names[entity.name] or always_protected_types[entity.type] then return true end + if config.always_protected_names[entity.name] or config.always_protected_types[entity.type] then return true end local entities = protected_entities[entity.surface.index] if not entities then return false end return entities[Protection.get_entity_key(entity)] == entity @@ -161,14 +173,13 @@ function Protection.is_position_protected(surface, position) return false end ---- Players are never checked against their own entities, and can be excluded by permission or admin status +--- Players are never checked against their own entities, and can be excluded by permission --- @param player LuaPlayer --- @param entity LuaEntity --- @return boolean local function is_ignored(player, entity) - if config.ignore_admins and player.admin then return true end if entity.last_user == nil or entity.last_user.index == player.index then return true end - if config.ignore_permission and Roles.player_has_permission(player, config.ignore_permission) then return true end + if Roles.player_has_permission(player, "exp_scenario.bypass.entity_protection") then return true end return false end @@ -187,14 +198,14 @@ local function raise_violation(event, player) script.raise_event(Protection.on_player_mined_protected, event) local entity = event.entity - local always_repeat = always_trigger_repeat_names[entity.name] or always_trigger_repeat_types[entity.type] + local always_repeat = config.always_trigger_repeat_names[entity.name] or config.always_trigger_repeat_types[entity.type] if always_repeat or player_repeats.count >= config.repeat_count then player_repeats.count = 0 script.raise_event(Protection.on_repeat_violation, event) end end ---- Raise the protection events when a protected entity is mined, then forget the entity +--- Raise the protection events when a protected entity is mined --- @param event EventData.on_pre_player_mined_item local function on_pre_player_mined_item(event) local entity = event.entity @@ -205,19 +216,41 @@ local function on_pre_player_mined_item(event) then raise_violation(event, player) end - - Protection.remove_entity(entity) end ---- Forget an entity once it no longer exists ---- @param event { entity: LuaEntity } -local function on_entity_removed(event) - Protection.remove_entity(event.entity) +--- Forget a protected entity once it is destroyed, unless another one took its place +--- @param registration_number uint64 +--- @param registration ExpScenario_Protection.Registration +local function forget_entity(registration_number, registration) + registrations[registration_number] = nil + local entities = protected_entities[registration.surface_index] + if entities and entities[registration.key] == registration.entity then + entities[registration.key] = nil + end +end + +--- @param event EventData.on_object_destroyed +local function on_object_destroyed(event) + local registration = registrations[event.registration_number] + if registration then + forget_entity(event.registration_number, registration) + end +end + +--- Entities destroyed while the feature was disabled were not forgotten +--- @param event EventData.ExpScenario.on_config_updated +local function on_config_updated(event) + if event.feature_name ~= feature.name or event.path ~= "enabled" then return end + for registration_number, registration in pairs(registrations) do + if not registration.entity.valid then + forget_entity(registration_number, registration) + end + end end --- Forget protected removals older than the repeat lifetime local function clear_old_repeats() - local old = game.tick - config.repeat_lifetime + local old = game.tick - config.repeat_minutes * 3600 for player_name, player_repeats in pairs(repeats) do if player_repeats.last <= old then repeats[player_name] = nil @@ -228,10 +261,8 @@ end local e = defines.events Protection.events[e.on_pre_player_mined_item] = on_pre_player_mined_item -Protection.events[e.on_space_platform_pre_mined] = on_entity_removed -Protection.events[e.on_robot_pre_mined] = on_entity_removed -Protection.events[e.on_entity_died] = on_entity_removed -Protection.events[e.script_raised_destroy] = on_entity_removed -Protection.on_nth_tick[config.refresh_rate] = clear_old_repeats +Protection.events[e.on_object_destroyed] = on_object_destroyed +Protection.events[Feature.events.on_config_updated] = on_config_updated +Protection.on_nth_tick[3600 * 5] = clear_old_repeats -return Protection +return feature:guard(Protection) diff --git a/exp_scenario/module/control/research.lua b/exp_scenario/module/control/research.lua index 4d885740..3852b553 100644 --- a/exp_scenario/module/control/research.lua +++ b/exp_scenario/module/control/research.lua @@ -1,48 +1,62 @@ ---[[ Control - Research -Various research related event handlers - -TODO Refactor this fully, this is temp to get it out of the research times gui file -]] - -local config = require("modules/exp_legacy/config/research") - ---- @param event EventData.on_research_finished -local function on_research_finished(event) - local research_name = event.research.name - if config.bonus_inventory.enabled and config.bonus_inventory.res[research_name] then - event.research.force[config.bonus_inventory.name] = math.min((event.research.level - 1) * config.bonus_inventory.rate, config.bonus_inventory.limit) - end - - if config.pollution_ageing_by_research and config.bonus_inventory.res[research_name] then - game.map_settings.pollution.ageing = math.min(10, event.research.level / 5) - end -end - ---- @param event EventData.on_research_started -local function on_research_started(event) - if config.limit_res[event.research.name] and event.research.level > config.limit_res[event.research.name] then - event.research.enabled = false - event.research.visible_when_disabled = true - local rq = event.research.force.research_queue - - for i = #rq, 1, -1 do - if rq[i] == event.research.name then - table.remove(rq, i) - end - end - - event.research.force.cancel_current_research() - event.research.force.research_queue = rq - end -end - -local e = defines.events - -return { - events = { - [e.on_research_finished] = on_research_finished, - [e.on_research_reversed] = on_research_finished, - [e.on_research_started] = on_research_started, - [e.on_research_queued] = on_research_started, - } -} +--[[ Control - Research +Various research related event handlers + +TODO Refactor this fully, this is temp to get it out of the research times gui file +]] + +local Feature = require("modules/exp_scenario/features") +local research_data = require("modules/exp_scenario/config/research_data") + +local feature, config = Feature.register("research", { + pollution_ageing_by_research = false, -- pollution ages faster with each level of the bonus inventory researches + bonus_inventory = { -- extra inventory slots for each level of these researches + enabled = true, + res = Feature.set{ "mining-productivity", "mining-productivity-2", "mining-productivity-3", "mining-productivity-4" }, + rate = 5, + limit = 20, + }, +}) + +local bonus_inventory_name = "character_inventory_slots_bonus" + +--- @param event EventData.on_research_finished +local function on_research_finished(event) + local research_name = event.research.name + if config.bonus_inventory.enabled and config.bonus_inventory.res[research_name] then + event.research.force[bonus_inventory_name] = math.min((event.research.level - 1) * config.bonus_inventory.rate, config.bonus_inventory.limit) + end + + if config.pollution_ageing_by_research and config.bonus_inventory.res[research_name] then + game.map_settings.pollution.ageing = math.min(10, event.research.level / 5) + end +end + +--- @param event EventData.on_research_started +local function on_research_started(event) + local limit = research_data.limit_res[event.research.name] + if limit and event.research.level > limit then + event.research.enabled = false + event.research.visible_when_disabled = true + local rq = event.research.force.research_queue + + for i = #rq, 1, -1 do + if rq[i] == event.research.name then + table.remove(rq, i) + end + end + + event.research.force.cancel_current_research() + event.research.force.research_queue = rq + end +end + +local e = defines.events + +return feature:guard{ + events = { + [e.on_research_finished] = on_research_finished, + [e.on_research_reversed] = on_research_finished, + [e.on_research_started] = on_research_started, + [e.on_research_queued] = on_research_started, + } +} diff --git a/exp_scenario/module/control/spawn_area.lua b/exp_scenario/module/control/spawn_area.lua index d4c23275..18134c35 100644 --- a/exp_scenario/module/control/spawn_area.lua +++ b/exp_scenario/module/control/spawn_area.lua @@ -2,7 +2,9 @@ Adds a custom spawn area with chests and afk turrets ]] -local config = require("modules.exp_legacy.config.spawn_area") +local Feature = require("modules/exp_scenario/features") +local feature = require("modules/exp_scenario/config/spawn_area") +local config = feature.config --- Apply an offset to a LuaPosition --- @param position MapPosition.struct @@ -33,7 +35,7 @@ end apply_offset_to_array(config.turrets.locations, config.turrets.offset, 1, 2) apply_offset_to_array(config.afk_belts.locations, config.afk_belts.offset, 1, 2) apply_offset_to_array(config.water.locations, config.water.offset, 1, 2) -apply_offset_to_array(config.water.locations, config.water.offset, 1, 2) +apply_offset_to_array(config.pattern.locations, config.pattern.offset, 1, 2) apply_offset_to_array(config.entities.locations, config.entities.offset, 2, 3) --- Get or create the force used for entities in spawn @@ -251,30 +253,26 @@ end local on_nth_tick = {} -if config.turrets.enabled then - --- Refill the ammo in the spawn turrets - on_nth_tick[config.turrets.refill_time] = function() - if game.tick < 10 then return end - update_turrets() - end +--- Refill the ammo in the spawn turrets +on_nth_tick[60 * 60 * 5] = function() + if game.tick < 10 or not config.turrets.enabled then return end + update_turrets() end -if config.resource_refill_nearby.enabled then - --- - on_nth_tick[config.resource_refill_nearby.refill_time] = function() - if game.tick < 10 then return end +--- Refill the resources near spawn +on_nth_tick[60 * 60 * 10] = function() + if game.tick < 10 or not config.resource_refill_nearby.enabled then return end - local force = game.forces.player - local surface = assert(game.get_surface("nauvis")) - local entities = surface.find_entities_filtered{ - position = force.get_spawn_position(surface), - radius = config.resource_refill_nearby.range, - name = config.resource_refill_nearby.resources_name - } + local force = game.forces.player + local surface = assert(game.get_surface("nauvis")) + local entities = surface.find_entities_filtered{ + position = force.get_spawn_position(surface), + radius = config.resource_refill_nearby.range, + name = config.resource_refill_nearby.resources_name + } - for _, ore in ipairs(entities) do - ore.amount = ore.amount + math.random(config.resource_refill_nearby.amount[1], config.resource_refill_nearby.amount[2]) - end + for _, ore in ipairs(entities) do + ore.amount = ore.amount + math.random(config.resource_refill_nearby.amount[1], config.resource_refill_nearby.amount[2]) end end @@ -302,9 +300,9 @@ end local e = defines.events -return { +return feature:guard{ on_nth_tick = on_nth_tick, events = { [e.on_player_created] = on_player_created, - } + }, } diff --git a/exp_scenario/module/control/station_auto_name.lua b/exp_scenario/module/control/station_auto_name.lua index 96b8fc09..061cc7ab 100644 --- a/exp_scenario/module/control/station_auto_name.lua +++ b/exp_scenario/module/control/station_auto_name.lua @@ -2,7 +2,13 @@ Automatically name stations when they are placed based on closest resource and direction from spawn ]] -local config = require("modules.exp_legacy.config.station_auto_name") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("station_auto_name", { + -- the name given to new stations, placeholders: + -- __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__ + station_name = "[L] __icon__", +}) local get_direction do local directions = { @@ -98,9 +104,9 @@ end local e = defines.events -return { +return feature:guard{ events = { [e.on_built_entity] = rename_station, [e.on_robot_built_entity] = rename_station, - } + }, } diff --git a/exp_scenario/module/control/vlayer.lua b/exp_scenario/module/control/vlayer.lua index 7d063557..c59d5a03 100644 --- a/exp_scenario/module/control/vlayer.lua +++ b/exp_scenario/module/control/vlayer.lua @@ -4,7 +4,43 @@ Stores solar panels, accumulators and landfill in a virtual layer which produces local ExpUtil = require("modules/exp_util") local Storage = require("modules/exp_util/storage") -local config = require("modules.exp_legacy.config.vlayer") +local Feature = require("modules/exp_scenario/features") +local items = require("modules/exp_scenario/config/vlayer_items") + +local feature, config = Feature.register("vlayer", { + unlimited_capacity = false, -- an unlimited energy capacity, accumulators are not required + unlimited_surface_area = false, -- an unlimited surface area, landfill is not required + modded_auto_downgrade = false, -- modded items are converted into their base game equivalent, the originals can not be recovered + power_on_space = false, -- energy interfaces can be built on platforms + power_on_space_research = { -- the research level needed to build them there + name = "research-productivity", + level = 10, + }, + mimic_surface = "nauvis", -- the surface the day cycle is copied from, empty to use the fixed cycle below + interface_limit = { -- how many of each interface can exist + energy = 1, -- >1 allows for disconnected power networks to receive power + circuit = 20, + storage_input = 20, + storage_output = 1, -- >0 allows for item teleportation of the allowed items + }, +}) + +local update_tick_storage = 60 +local update_tick_energy = 10 + +--- The day cycle used when there is no mimic surface, see LuaSurface for details +local surface_settings = { + always_day = false, + solar_power_multiplier = 1, + min_brightness = 0.15, + ticks_per_day = 25200, + daytime = 0, + dusk = 0.25, + evening = 0.45, + morning = 0.55, + dawn = 0.75, + +} local floor = math.floor local min = math.min @@ -58,6 +94,9 @@ local mega = 1000000 --- @class ExpScenario_Vlayer local Vlayer = { + --- The config of the feature, read only + config = config, + feature = feature, --- @package events = {}, --- @package @@ -66,11 +105,11 @@ local Vlayer = { --- The properties of every allowed item, modded items are derived from their base game equivalent local allowed_items = {} --- @type table -for name, properties in pairs(config.allowed_items) do +for name, properties in pairs(items.allowed_items) do allowed_items[name] = properties end -for name, properties in pairs(config.modded_items) do - local base = config.allowed_items[properties.base_game_equivalent] +for name, properties in pairs(items.modded_items) do + local base = items.allowed_items[properties.base_game_equivalent] local multiplier = properties.multiplier allowed_items[name] = { starting_value = properties.starting_value or 0, @@ -103,7 +142,7 @@ local vlayer_data = { power_items = {}, energy = 0, }, - surface = table.deep_copy(config.surface), + surface = table.deep_copy(surface_settings), } Storage.register(vlayer_data, function(tbl) @@ -490,7 +529,7 @@ local function handle_input_interfaces() end if removed > 0 then - local modded = config.modded_items[name] + local modded = items.modded_items[name] if modded and config.modded_auto_downgrade then Vlayer.insert_item(modded.base_game_equivalent, stored * modded.multiplier) elseif vlayer_data.storage.power_items[name] then @@ -595,7 +634,7 @@ end local function handle_energy_interfaces() local storage = vlayer_data.storage local properties = vlayer_data.properties - local production = properties.production * mega * (config.update_tick_energy / 60) + local production = properties.production * mega * (update_tick_energy / 60) storage.energy = storage.energy + floor(production * get_production_multiplier()) local interfaces = vlayer_data.interfaces.energy @@ -634,7 +673,7 @@ end --- Take the day cycle from the mimic surface when it exists, otherwise from the config local function update_surface() - if config.mimic_surface then + if config.mimic_surface ~= "" then local surface = game.get_surface(config.mimic_surface) if surface then vlayer_data.surface = surface @@ -644,7 +683,7 @@ local function update_surface() -- The config copy never has an index, so this avoids replacing it every time a surface changes if not vlayer_data.surface.index then - vlayer_data.surface = table.deep_copy(config.surface) + vlayer_data.surface = table.deep_copy(surface_settings) end end @@ -667,7 +706,12 @@ Vlayer.on_init = update_surface -- The default surface exists before on_surface_ Vlayer.events[e.on_surface_created] = update_surface Vlayer.events[e.on_surface_renamed] = update_surface Vlayer.events[e.on_surface_imported] = update_surface -Vlayer.on_nth_tick[config.update_tick_storage] = update_storage -Vlayer.on_nth_tick[config.update_tick_energy] = update_energy +Vlayer.events[Feature.events.on_config_updated] = function(event) + if event.feature_name == feature.name and event.path == "mimic_surface" then + update_surface() + end +end +Vlayer.on_nth_tick[update_tick_storage] = update_storage +Vlayer.on_nth_tick[update_tick_energy] = update_energy -return Vlayer +return feature:guard(Vlayer) diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua new file mode 100644 index 00000000..1c25096c --- /dev/null +++ b/exp_scenario/module/features.lua @@ -0,0 +1,282 @@ +--[[-- ExpScenario - Features +Features which the controller can enable, disable, and configure while the game is running + +The config table is changed in place when the controller sends values, so read +them where they are used rather than caching them when the file loads. Values +read while the file loads, such as on_nth_tick periods, are not listed in features.ts. + +--- Registering a feature, small configs live at the top of the file which uses them: +local Feature = require("modules/exp_scenario/features") +local feature, config = Feature.register("my_feature", { + show_message = true, -- booleans, numbers, and strings + role_id = Feature.optional("number"), -- no default + item_names = Feature.set{ "iron-plate" }, -- indexed by name + surface_names = Feature.list{ "nauvis" }, + section = { count = 1 }, -- addressed as "section.count" +}) + +--- Handlers do nothing while the feature is disabled: +return feature:guard{ + events = { [defines.events.on_player_joined_game] = on_player_joined_game }, +} + +--- Toolbar buttons and commands: +Gui.toolbar.create_button{ visible = function(player) return feature:is_enabled() end } +Commands.new("my_command", { "..." }):add_flags{ feature = feature } + +--- Changes are raised per value: +[Feature.events.on_config_updated] = function(event) end, -- { feature_name, path, old_value, new_value } +]] + +local Storage = require("modules/exp_util/storage") + +--- @class ExpScenario.FeatureField +--- @field path string +--- @field parent table +--- @field key string +--- @field default any +--- @field type string "boolean", "number", "string", "list", "set", or a type which can not be set + +--- @class ExpScenario.Features +local Feature = { + _registered = {}, --- @type table + events = { + --- @type EventData.ExpScenario.on_config_updated + on_config_updated = script.generate_event_name(), + }, +} + +--- @class ExpScenario.Feature +--- @field name string +--- @field config table +--- @field fields table By path +--- @field values table Last received from the controller, restored from storage on load +Feature._prototype = {} + +--- @class EventData.ExpScenario.on_config_updated : EventData +--- @field feature_name string +--- @field path string "enabled" when the feature is enabled or disabled +--- @field old_value any +--- @field new_value any + +local Feature_mt = { + __index = Feature._prototype, +} + +--- Only the values are stored, the rest is rebuilt when the file loads +local stored_values = {} --- @type table> +Storage.register(stored_values, function(tbl) + stored_values = tbl + for name, feature in pairs(Feature._registered) do + feature.values = tbl[name] or {} + for path, field in pairs(feature.fields) do + feature:_set_field(field, feature.values[path]) + end + end +end) + +--- @param value any +--- @return boolean +local function is_string_list(value) + if type(value) ~= "table" then return false end + if next(value) ~= nil and value[1] == nil then return false end + for _, item in pairs(value) do + if type(item) ~= "string" then return false end + end + return true +end + +--- Lists and sets are compared by their contents +--- @param a any +--- @param b any +--- @return boolean +local function values_equal(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + if table_size(a) ~= table_size(b) then return false end + for key, value in pairs(a) do + if b[key] ~= value then return false end + end + return true +end + +--- Marks a default made by Feature.list, Feature.set, or Feature.optional +local typed_mt = {} + +--- @param list string[] +--- @return table +local function list_to_set(list) + local set = {} + for _, item in ipairs(list) do + set[item] = true + end + return set +end + +--- Nested tables with string keys are sections +--- @param fields table +--- @param tbl table +--- @param prefix string +local function collect_fields(fields, tbl, prefix) + for key, value in pairs(tbl) do + if type(key) == "string" then + local path = prefix .. key + local field = { path = path, parent = tbl, key = key, default = value, type = type(value) } + if type(value) == "table" then + if getmetatable(value) == typed_mt then + field.type = value.type + field.default = value.default + tbl[key] = value.default + elseif type(next(value)) == "string" then + collect_fields(fields, value, path .. ".") + field = nil + end + end + if field then + fields[path] = field + end + end + end +end + +--- A value with no default, the argument is the type it accepts +--- @param value_type "boolean" | "number" | "string" +--- @return any +function Feature.optional(value_type) + return setmetatable({ type = value_type }, typed_mt) +end + +--- A list of strings +--- @param items string[] +--- @return string[] +function Feature.list(items) + return setmetatable({ type = "list", default = items }, typed_mt) +end + +--- A set of strings, the controller sends a list +--- @param items string[] +--- @return table +function Feature.set(items) + return setmetatable({ type = "set", default = list_to_set(items) }, typed_mt) +end + +--- Features are enabled unless the config says otherwise +--- @param name string +--- @param config table? +--- @return ExpScenario.Feature feature +--- @return table config +function Feature.register(name, config) + assert(Feature._registered[name] == nil, "Feature already registered: " .. name) + config = config or {} + if config.enabled == nil then + config.enabled = true + end + + local fields = {} + collect_fields(fields, config, "") + + local feature = setmetatable({ + name = name, + config = config, + fields = fields, + values = {}, + }, Feature_mt) --[[@as ExpScenario.Feature]] + + Feature._registered[name] = feature + return feature, config +end + +--- @param name string +--- @return ExpScenario.Feature +function Feature.get(name) + return Feature._registered[name] or error("Unknown feature: " .. tostring(name), 2) +end + +--- @param field ExpScenario.FeatureField +--- @param value any +--- @return boolean +local function is_valid(field, value) + if field.type == "list" or field.type == "set" then + return is_string_list(value) + end + return type(value) == field.type and (field.type == "boolean" or field.type == "number" or field.type == "string") +end + +--- Set a value, nil or an invalid value means the default +--- @package +--- @param field ExpScenario.FeatureField +--- @param value any +--- @return any # The value which was set +function Feature._prototype:_set_field(field, value) + if value ~= nil and not is_valid(field, value) then + log("[WARNING] Invalid value for " .. self.name .. "." .. field.path .. ", using the default") + value = nil + end + if value == nil then + value = field.default + elseif field.type == "set" then + value = list_to_set(value) + end + field.parent[field.key] = value + return value +end + +--- @return boolean +function Feature._prototype:is_enabled() + return self.config.enabled +end + +--- Make the event and on_nth_tick handlers of a lib do nothing while the feature is disabled +--- @generic T : table +--- @param lib T +--- @return T +function Feature._prototype:guard(lib) + local config = self.config + for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do + for key, handler in pairs(handlers) do + handlers[key] = function(event) + if config.enabled then + return handler(event) + end + end + end + end + + return lib +end + +--- Replace every value, then raise on_config_updated for each one which changed +--- @param values table By path, "enabled" included +function Feature._prototype:update_config(values) + self.values = values + stored_values[self.name] = values + + local updates = {} + for path, field in pairs(self.fields) do + local old_value = field.parent[field.key] + local new_value = self:_set_field(field, values[path]) + if not values_equal(old_value, new_value) then + updates[#updates + 1] = { feature_name = self.name, path = path, old_value = old_value, new_value = new_value } + end + end + + for _, update in ipairs(updates) do + script.raise_event(Feature.events.on_config_updated, update) + end +end + +--- @class ExpScenario.FeatureUpdate +--- @field name string +--- @field values table + +--- Called over rcon by the instance plugin, unknown features are ignored +--- @param updates ExpScenario.FeatureUpdate[] +function Feature.receive_update(updates) + for _, update in ipairs(updates) do + local feature = Feature._registered[update.name] + if feature then + feature:update_config(update.values) + end + end +end + +return Feature diff --git a/exp_scenario/module/globals.lua b/exp_scenario/module/globals.lua new file mode 100644 index 00000000..bdb9ef6a --- /dev/null +++ b/exp_scenario/module/globals.lua @@ -0,0 +1,6 @@ +--- @diagnostic disable: global-in-non-module + +-- Access using `/sc exp_scenario.features.foo()` +exp_scenario = { + features = require("modules/exp_scenario/features"), +} diff --git a/exp_scenario/module/gui/autofill.lua b/exp_scenario/module/gui/autofill.lua index 8c663af4..f5fec96e 100644 --- a/exp_scenario/module/gui/autofill.lua +++ b/exp_scenario/module/gui/autofill.lua @@ -2,9 +2,12 @@ Adds a config menu for setting autofill of placed entities ]] +local Feature = require("modules/exp_scenario/features") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") -local config = require("modules.exp_legacy.config.gui.autofill") +local config = require("modules/exp_scenario/config/autofill") + +local feature = Feature.register("autofill") local FlyingText = require("modules/exp_util/flying_text") local min = math.min @@ -330,7 +333,7 @@ Gui.toolbar.create_button{ sprite = config.icon, tooltip = { "exp-gui_autofill.tooltip-main" }, visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.autofill") + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.autofill") end } @@ -396,9 +399,9 @@ end local e = defines.events -return { +return feature:guard{ elements = Elements, events = { [e.on_built_entity] = on_built_entity, - } + }, } diff --git a/exp_scenario/module/gui/module_inserter.lua b/exp_scenario/module/gui/module_inserter.lua index f930bd34..dd6650c4 100644 --- a/exp_scenario/module/gui/module_inserter.lua +++ b/exp_scenario/module/gui/module_inserter.lua @@ -9,17 +9,29 @@ local Roles = require("modules/exp_roles") local Selection = require("modules/exp_util/selection") local SelectArea = Selection.connect("ModuleArea") -local config = require("modules/exp_legacy/config/module") +local Feature = require("modules/exp_scenario/features") +local machine_sets = require("modules/exp_scenario/config/module_inserter_machines") + +local feature, config = Feature.register("module_inserter", { + copy_paste_module = true, -- copying a machine also copies its modules + copy_paste_rotation = false, -- copying a machine also copies its rotation +}) + +local module_slots_per_row = 4 +local module_slot_max = 8 + +--- The machines of the active mod sets +local machines = {} --- @class ExpGui_ModuleInserter.elements local Elements = {} --- Load all the valid machines from the config file local machine_names = {} -for mod_name, machine_set in pairs(config.machine_sets) do +for mod_name, machine_set in pairs(machine_sets) do if script.active_mods[mod_name] then for machine_name, v in pairs(machine_set) do - config.machines[machine_name] = v + machines[machine_name] = v table.insert(machine_names, machine_name) end end @@ -137,7 +149,7 @@ Elements.module_selector = Gui.define("module_inserter/module_selector") Elements.module_table = Gui.define("module_inserter/module_table") :draw(function(def, parent) --- @cast def ExpGui_ModuleInserter.elements.module_table - local slots_per_row = config.module_slots_per_row + 1 + local slots_per_row = module_slots_per_row + 1 return Gui.elements.scroll_table(parent, 280, slots_per_row) end) :element_data{} --[[@as any]] @@ -163,12 +175,12 @@ function Elements.module_table.add_row(module_table) } -- Add the module selectors and row separators - local slots_per_row = config.module_slots_per_row + 1 - for i = 1, config.module_slot_max do + local slots_per_row = module_slots_per_row + 1 + for i = 1, module_slot_max do if i % slots_per_row == 0 then row_separators[#row_separators + 1] = module_table.add{ type = "flow", visible = false } end - module_selectors[i] = Elements.module_selector(module_table, i <= config.module_slots_per_row) + module_selectors[i] = Elements.module_selector(module_table, i <= module_slots_per_row) end end @@ -199,7 +211,7 @@ function Elements.module_table.reset_row(module_table, machine_selector) separator.visible = false end for i, selector in pairs(row.module_selectors) do - selector.visible = i <= config.module_slots_per_row + selector.visible = i <= module_slots_per_row selector.enabled = false selector.elem_value = nil end @@ -214,16 +226,16 @@ function Elements.module_table.refresh_row(module_table, machine_selector, machi local row = assert(rows[machine_selector.index]) local active_module_count = assert(prototypes.entity[machine_name].module_inventory_size) - local visible_row_count = math.ceil(active_module_count / config.module_slots_per_row) - local visible_module_count = visible_row_count * config.module_slots_per_row - local module_elem_value = { name = config.machines[machine_name].module } + local visible_row_count = math.ceil(active_module_count / module_slots_per_row) + local visible_module_count = visible_row_count * module_slots_per_row + local module_elem_value = { name = machines[machine_name].module } for i, separator in pairs(row.row_separators) do separator.visible = i < visible_row_count end for i, selector in pairs(row.module_selectors) do if i <= active_module_count then - if config.machines[machine_name].prod then + if machines[machine_name].prod then selector.elem_filters = elem_filter.with_prod else selector.elem_filters = elem_filter.no_prod @@ -259,7 +271,7 @@ Gui.toolbar.create_button{ sprite = "item/productivity-module-3", tooltip = { "exp-gui_module-inserter.tooltip-main" }, visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.module") + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.module") end } @@ -499,7 +511,7 @@ end local e = defines.events -return { +return feature:guard{ elements = Elements, events = { [e.on_entity_settings_pasted] = on_entity_settings_pasted, diff --git a/exp_scenario/module/gui/player_bonus.lua b/exp_scenario/module/gui/player_bonus.lua index 1fadeccc..ac940a3c 100644 --- a/exp_scenario/module/gui/player_bonus.lua +++ b/exp_scenario/module/gui/player_bonus.lua @@ -4,7 +4,18 @@ Adds a gui that allows players to apply various bonuses local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") -local config = require("modules/exp_legacy/config/bonus") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("player_bonus", { + points = { -- how many points a player can spend on bonuses + base = 174, -- the points of the points role, the standard value of each bonus adds up to this + increase_percentage_per_role_level = 0.03, -- extra points as a fraction of the base for each role above the points role + role_id = Feature.optional("number"), -- the role the base points apply to, without one every player gets the base + }, +}) + +--- Ticks between applications of the periodic bonuses +local periodic_bonus_rate = 300 local Vlayer = require("modules/exp_scenario/control/vlayer") local format_number = require("util").format_number @@ -19,11 +30,12 @@ local Elements = {} --- @field is_percentage boolean --- @field is_special boolean --- @field value_step number +--- @field combined_bonus string[]? Bonuses applied together with this one --- @field _cost_scale number --- For perf calculate the division of scale against cost ahead of time ---- @type table -local player_bonus = config.player_bonus +--- @type ExpGui_PlayerBonus.bonus_data[] +local player_bonus = require("modules/exp_scenario/config/player_bonus") for _, bonus_data in pairs(player_bonus) do bonus_data._cost_scale = bonus_data.cost / bonus_data.scale @@ -71,8 +83,8 @@ do local _points_limit = {} --- @type table local roles = Roles.get_roles() local positions = {} for index, role in ipairs(roles) do positions[role.id] = index end - local role = assert(Roles.get_role_by_name(config.points.role_name), "Bonus points role does not exist") - local role_diff = positions[role.id] - positions[Roles.get_player_highest_role(player).id] + local role = config.points.role_id and Roles.get_role(config.points.role_id) + local role_diff = role and positions[role.id] - positions[Roles.get_player_highest_role(player).id] or 0 local points_limit = math.floor(config.points.base * (1 + config.points.increase_percentage_per_role_level * role_diff)) _points_limit[player.index] = points_limit return points_limit @@ -375,7 +387,7 @@ Elements.container = Gui.define("player_bonus/container") Elements.reset_button.link_apply_button(elements.reset_button, elements.apply_button) Elements.apply_button.link_reset_button(elements.apply_button, elements.reset_button) - for _, bonus_data in pairs(config.player_bonus) do + for _, bonus_data in pairs(player_bonus) do --- @cast bonus_data ExpGui_PlayerBonus.bonus_data Elements.bonus_table.add_row(bonus_table, bonus_data, elements) end @@ -409,7 +421,7 @@ function Elements.container.clear_player_bonus(player) if not player.character then return end - for _, bonus_data in pairs(config.player_bonus) do + for _, bonus_data in pairs(player_bonus) do if not bonus_data.is_special then player[bonus_data.name] = 0 if bonus_data.combined_bonus then @@ -429,7 +441,7 @@ function Elements.container.apply_player_bonus(player) end local player_data = Elements.container.data[player] - for _, bonus_data in pairs(config.player_bonus) do + for _, bonus_data in pairs(player_bonus) do if not bonus_data.is_special then local value = player_data[bonus_data.name] or 0 player[bonus_data.name] = value @@ -462,27 +474,45 @@ Gui.toolbar.create_button{ sprite = "item/exoskeleton-equipment", tooltip = { "exp-gui_player-bonus.tooltip-main" }, visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.bonus") + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.bonus") end } --- Recalculate and apply the bonus for a player +--- Recalculate the limit of a player, their bonus is removed when it is exceeded +--- @param player LuaPlayer +--- @return boolean # False when the bonus was removed +local function refresh_points_limit(player) + Elements.bonus_used._clear_points_limit_cache(player) + local bonus_cost = Elements.container.calculate_cost(player) + local within_limit = Elements.bonus_used.refresh_player(player, bonus_cost) + if not within_limit or not Roles.player_has_permission(player, "exp_scenario.gui.bonus") then + Elements.container.clear_player_bonus(player) + return false + end + return true +end + local function recalculate_bonus(event) local player = Gui.get_player(event) - if event.name == Roles.events.on_player_roles_changed then - -- If the player's roles changed then we will need to recalculate their limit - Elements.bonus_used._clear_points_limit_cache(player) - local bonus_cost = Elements.container.calculate_cost(player) - local within_limit = Elements.bonus_used.refresh_player(player, bonus_cost) - if not within_limit or not Roles.player_has_permission(player, "exp_scenario.gui.bonus") then - Elements.container.clear_player_bonus(player) - return - end + if event.name == Roles.events.on_player_roles_changed and not refresh_points_limit(player) then + return end Elements.container.apply_player_bonus(player) end +--- The points of every player change with the points settings +--- @param event EventData.ExpScenario.on_config_updated +local function on_config_updated(event) + if event.feature_name ~= feature.name then return end + for _, player in pairs(game.connected_players) do + if refresh_points_limit(player) then + Elements.container.apply_player_bonus(player) + end + end +end + --- Apply periodic bonus to a player --- @param player LuaPlayer local function apply_personal_battery_recharge(player) @@ -501,7 +531,7 @@ local function apply_personal_battery_recharge(player) return -- No grid or already full end - local recharge_amount = Elements.container.get_player_bonus(player, "personal_battery_recharge") * 100000 * config.periodic_bonus_rate / 6 + local recharge_amount = Elements.container.get_player_bonus(player, "personal_battery_recharge") * 100000 * periodic_bonus_rate / 6 for _, equipment in pairs(grid.equipment) do if equipment.energy < equipment.max_energy then @@ -524,13 +554,14 @@ end local e = defines.events -return { +return feature:guard{ elements = Elements, events = { [e.on_player_respawned] = recalculate_bonus, [Roles.events.on_player_roles_changed] = recalculate_bonus, + [Feature.events.on_config_updated] = on_config_updated, }, on_nth_tick = { - [config.periodic_bonus_rate] = apply_periodic_bonus_online, + [periodic_bonus_rate] = apply_periodic_bonus_online, } } diff --git a/exp_scenario/module/gui/player_list.lua b/exp_scenario/module/gui/player_list.lua index e6fc9058..009f7bb4 100644 --- a/exp_scenario/module/gui/player_list.lua +++ b/exp_scenario/module/gui/player_list.lua @@ -5,7 +5,7 @@ Adds a player list to show names and play time; also includes action buttons whi local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") -local config = require("modules/exp_legacy/config/gui/player_list_actions") +local actions = require("modules/exp_scenario/gui/player_list_actions") --- @class ExpGui_PlayerList.elements local Elements = {} @@ -100,7 +100,7 @@ Elements.reason_confirm = Gui.define("player_list/reason_confirm") } :on_click(function(_, player, element) local action_name = Elements.container.get_selected_action(player) - local button_data = action_name and config.buttons[action_name] + local button_data = action_name and actions.buttons[action_name] if button_data and button_data.reason_callback then local reason = assert(element.parent).entry.text --[[@as string?]] if reason == nil or not reason:find("%S") then reason = "no reason given" end @@ -330,7 +330,7 @@ Elements.action_bar = Gui.define("player_list/action_bar") action_bar.visible = false Elements.close_action_bar(action_bar) - for action_name, button_data in pairs(config.buttons) do + for action_name, button_data in pairs(actions.buttons) do local permission_flow = action_bar.add{ type = "flow", name = action_name } permission_flow.visible = false for _, button in ipairs(button_data) do @@ -356,7 +356,7 @@ function Elements.action_bar.refresh(action_bar, player, selected_player) end action_bar.visible = true - for action_name, buttons in pairs(config.buttons) do + for action_name, buttons in pairs(actions.buttons) do local flow = action_bar[action_name] if buttons.auth and not buttons.auth(player, selected_player) then flow.visible = false @@ -483,7 +483,7 @@ local function select_action(player, selected_action) Elements.container.set_selected_action(player, selected_action) Elements.player_table.refresh_player(player) end -config.set_accessors(Elements.container.get_selected_player, select_action) +actions.set_accessors(Elements.container.get_selected_player, select_action) --- Add the element to the left flow with a toolbar button Gui.add_left_element(Elements.container, true) diff --git a/exp_legacy/module/config/gui/player_list_actions.lua b/exp_scenario/module/gui/player_list_actions.lua similarity index 92% rename from exp_legacy/module/config/gui/player_list_actions.lua rename to exp_scenario/module/gui/player_list_actions.lua index 7f0a1270..a84f3956 100644 --- a/exp_legacy/module/config/gui/player_list_actions.lua +++ b/exp_scenario/module/gui/player_list_actions.lua @@ -1,9 +1,7 @@ ---- Config for the different action buttons that show on the player list; --- each button has the button define(s) given along side an auth function, and optional reason callback; --- if a reason callback is used then Store.set(action_name_store,player.name,'BUTTON_NAME') should be called during on_click; --- buttons can be removed from the gui by commenting them out of the config at the bottom of this file; --- the key used for the name of the button is the permission name used by the role system; --- @config Player-List +--[[-- Gui - Player List Actions +The action buttons shown on the player list, each with its auth function and optional reason callback. +The key of each button is the permission name used by the role system. +]] local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") diff --git a/exp_scenario/module/gui/quick_actions.lua b/exp_scenario/module/gui/quick_actions.lua index 2d9c7cdb..28c9ec37 100644 --- a/exp_scenario/module/gui/quick_actions.lua +++ b/exp_scenario/module/gui/quick_actions.lua @@ -4,6 +4,7 @@ Adds a few buttons for common actions local Gui = require("modules/exp_gui") local Commands = require("modules/exp_commands") +local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local addon_artillery = require("modules/exp_scenario/commands/artillery") @@ -126,9 +127,19 @@ local function on_role_changed(event) Elements.container.refresh_player(player) end +--- Actions for commands of a disabled feature are hidden +--- @param event EventData.ExpScenario.on_config_updated +local function on_config_updated(event) + if event.path ~= "enabled" then return end + for _, player in pairs(game.connected_players) do + Elements.container.refresh_player(player) + end +end + return { elements = Elements, events = { [Roles.events.on_player_roles_changed] = on_role_changed, + [Feature.events.on_config_updated] = on_config_updated, } } diff --git a/exp_scenario/module/gui/research_milestones.lua b/exp_scenario/module/gui/research_milestones.lua index a7e06846..2a84364f 100644 --- a/exp_scenario/module/gui/research_milestones.lua +++ b/exp_scenario/module/gui/research_milestones.lua @@ -5,7 +5,12 @@ Adds a gui for tracking research milestones local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") -local config = require("modules/exp_legacy/config/research") +local Feature = require("modules/exp_scenario/features") +local research_data = require("modules/exp_scenario/config/research_data") + +local feature, config = Feature.register("research_milestones", { + log_file = "log/research.log", -- where the milestone times are written +}) local table_to_json = helpers.table_to_json local write_file = helpers.write_file @@ -34,18 +39,10 @@ local research_targets = { length = 0, } ---- Select the mod set to be used for milestones -for _, mod_name in ipairs(config.mod_set_lookup) do - if script.active_mods[mod_name] then - config.mod_set = mod_name - break - end -end - do --- Calculate the research targets local research_index = 1 local total_time = 0 - for name, time in pairs(config.milestone[config.mod_set]) do + for name, time in pairs(research_data.milestone[research_data.mod_set]) do if prototypes.technology[name] and (not prototypes.technology[name].hidden) then research_targets.index_lookup[name] = research_index total_time = total_time + time * 60 @@ -328,7 +325,7 @@ function Elements.container.append_log_line(force) result_data[name] = force_data[research_index] end - write_file(config.file.name, table_to_json(result_data) .. "\n", true, 0) + write_file(config.log_file, table_to_json(result_data) .. "\n", true, 0) end --- Add the element to the left flow with a toolbar button @@ -339,7 +336,7 @@ Gui.toolbar.create_button{ sprite = "item/space-science-pack", tooltip = { "exp-gui_research-milestones.tooltip-main" }, visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.research") + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.research") end } @@ -350,8 +347,8 @@ local function on_research_finished(event) local force = event.research.force -- Check if the log should be updated and print a message to chat - if config.inf_res[config.mod_set][research_name] then - local log_requirement = config.file.final_res[config.mod_set] + if research_data.inf_res[research_data.mod_set][research_name] then + local log_requirement = research_data.final_res[research_data.mod_set] if research_name == log_requirement.name and research_level == log_requirement.level + 1 then Elements.container.append_log_line(force) end @@ -394,7 +391,7 @@ end local e = defines.events -return { +return feature:guard{ elements = Elements, events = { [e.on_research_finished] = on_research_finished, diff --git a/exp_scenario/module/gui/rocket_info.lua b/exp_scenario/module/gui/rocket_info.lua index e0b9074a..8958dc2a 100644 --- a/exp_scenario/module/gui/rocket_info.lua +++ b/exp_scenario/module/gui/rocket_info.lua @@ -5,7 +5,25 @@ Adds a rocket information gui which shows general stats, milestones and build pr local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") -local config = require("modules/exp_legacy/config/gui/rockets") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("rocket_info", { + show_stats = true, -- the stats section + show_first_rocket = true, -- when the first rocket was launched + show_last_rocket = true, -- when the last rocket was launched + show_fastest_rocket = true, -- the time taken for the fastest rocket + show_total_rockets = true, -- the total number of rockets launched + show_game_avg = true, -- the average across the entire map time + show_milestones = true, -- the milestones section + show_progress = true, -- the build progress section + allow_zoom_to_map = true, -- the zoom to map button +}) + +--- Each number is one statistic, 5 means the average time taken for the last 5 rockets +local rolling_avg = { 5, 10, 25 } + +--- Each number is one statistic, 5 means the time that the 5th rocket was launched +local milestones = { 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000 } --- @class ExpGui_RocketInfo.elements local Elements = {} @@ -26,7 +44,7 @@ local font_color = { -- The largest rolling average is used to know when an old launch time can be discarded local largest_rolling_avg = 0 -for _, avg_over in pairs(config.stats.rolling_avg) do +for _, avg_over in pairs(rolling_avg) do if avg_over > largest_rolling_avg then largest_rolling_avg = avg_over end @@ -110,7 +128,7 @@ Elements.position_label = Gui.define("rocket_info/position_label") ) :on_click(function(def, player, element) --- @cast def ExpGui_RocketInfo.elements.position_label - if not config.progress.allow_zoom_to_map then return end + if not config.allow_zoom_to_map then return end local entity = def.data[element] if not entity or not entity.valid then return end player.set_controller{ type = defines.controllers.remote, position = entity.position, surface = entity.surface } @@ -142,34 +160,34 @@ function Elements.stats_table.calculate_row_data(force) local force_rockets = force.rockets_launched local rows = {} - if config.stats.show_first_rocket then + if config.show_first_rocket then local value = stats.first_launch or 0 rows[#rows + 1] = { key = "first-launch", name = "first-launch", value = time_formats.caption_hours(value), tooltip = time_formats.tooltip_hours(value) } end - if config.stats.show_last_rocket then + if config.show_last_rocket then local value = stats.last_launch or 0 rows[#rows + 1] = { key = "last-launch", name = "last-launch", value = time_formats.caption_hours(value), tooltip = time_formats.tooltip_hours(value) } end - if config.stats.show_fastest_rocket then + if config.show_fastest_rocket then local value = stats.fastest_launch or 0 rows[#rows + 1] = { key = "fastest-launch", name = "fastest-launch", value = time_formats.caption_hours(value), tooltip = time_formats.tooltip_hours(value) } end - if config.stats.show_total_rockets then + if config.show_total_rockets then local total_rockets = get_game_rocket_count() if total_rockets == 0 then total_rockets = 1 end local percentage = math.floor(force_rockets / total_rockets * 1000) / 10 rows[#rows + 1] = { key = "total-rockets", name = "total-rockets", value = tostring(force_rockets), tooltip = { "exp-gui_rocket-info.value-tooltip-total-rockets", percentage } } end - if config.stats.show_game_avg then + if config.show_game_avg then local avg = force_rockets > 0 and math.floor(game.tick / force_rockets) or 0 rows[#rows + 1] = { key = "avg-launch", name = "avg-launch", value = time_formats.caption(avg), tooltip = time_formats.tooltip(avg) } end - for _, avg_over in pairs(config.stats.rolling_avg) do + for _, avg_over in pairs(rolling_avg) do local avg = get_rolling_average(force, avg_over) rows[#rows + 1] = { key = "avg-launch-n-" .. avg_over, name = "avg-launch-n", subname = avg_over, value = time_formats.caption(avg), tooltip = time_formats.tooltip(avg) } end @@ -258,7 +276,7 @@ function Elements.milestones_table.calculate_row_data(force) local force_rockets = force.rockets_launched local rows = {} - for _, milestone in ipairs(config.milestones) do + for _, milestone in ipairs(milestones) do -- The milestones config mixes the show_milestones flag with the milestone numbers if type(milestone) == "number" then if milestone <= force_rockets then @@ -421,7 +439,7 @@ end --- @param row_data ExpGui_RocketInfo.elements.progress_table.row_data function Elements.progress_table.add_row(progress_table, row_data) local rows = Elements.progress_table.data[progress_table].rows - local zoom_tooltip = config.progress.allow_zoom_to_map and { "exp-gui_rocket-info.progress-label-tooltip" } or nil + local zoom_tooltip = config.allow_zoom_to_map and { "exp-gui_rocket-info.progress-label-tooltip" } or nil local x = Elements.position_label(progress_table, { caption = row_data.x, tooltip = zoom_tooltip, entity = row_data.entity }) local y = Elements.position_label(progress_table, { caption = row_data.y, tooltip = zoom_tooltip, entity = row_data.entity }) @@ -544,17 +562,17 @@ Elements.container = Gui.define("rocket_info/container") local force = player.force --[[@as LuaForce]] local force_data = def._get_force_data(force) - if config.stats.show_stats then + if config.show_stats then local row_data = Elements.stats_table.calculate_row_data(force) Elements.stats_table.refresh(def.add_section(container, "stats", Elements.stats_table), row_data) end - if config.milestones.show_milestones then + if config.show_milestones then local row_data = Elements.milestones_table.calculate_row_data(force) Elements.milestones_table.refresh(def.add_section(container, "milestones", Elements.milestones_table), row_data) end - if config.progress.show_progress then + if config.show_progress then local row_data = Elements.progress_table.calculate_row_data_all(force_data.silos) Elements.progress_table.refresh(def.add_section(container, "progress", Elements.progress_table), row_data) end @@ -651,7 +669,7 @@ Gui.toolbar.create_button{ sprite = "item/rocket-silo", tooltip = { "exp-gui_rocket-info.tooltip-main" }, visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.rocket_info") + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.rocket_info") end } @@ -678,7 +696,7 @@ local function on_cargo_pod_finished_ascending(event) -- Discard the launch time that is no longer needed by any rolling average unless it is a milestone local remove_rocket = rockets_launched - largest_rolling_avg - if remove_rocket > 0 and not table.array_contains(config.milestones, remove_rocket) then + if remove_rocket > 0 and not table.array_contains(milestones, remove_rocket) then times[remove_rocket] = nil end @@ -721,7 +739,7 @@ end local e = defines.events -return { +return feature:guard{ elements = Elements, events = { [e.on_cargo_pod_finished_ascending] = on_cargo_pod_finished_ascending, diff --git a/exp_scenario/module/gui/science_production.lua b/exp_scenario/module/gui/science_production.lua index c2ef7273..72854bf2 100644 --- a/exp_scenario/module/gui/science_production.lua +++ b/exp_scenario/module/gui/science_production.lua @@ -6,7 +6,28 @@ local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local Colors = require("modules/exp_util/include/color") local Roles = require("modules/exp_roles") -local config = require("modules/exp_legacy/config/gui/science") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("science_production", { + show_eta = true, -- the eta for research completion + color_flux = 0.1, -- the fluctuation allowed in production before the icon changes color +}) + +--- The science packs shown, those not in the game are removed below +local science_packs = { + "automation-science-pack", + "logistic-science-pack", + "military-science-pack", + "chemical-science-pack", + "production-science-pack", + "utility-science-pack", + "space-science-pack", + "metallurgic-science-pack", + "agricultural-science-pack", + "electromagnetic-science-pack", + "cryogenic-science-pack", + "promethium-science-pack", +} local _format_number = require("util").format_number local clock_time_format = ExpUtil.format_time_factory_locale{ format = "clock", hours = true, minutes = true, seconds = true } @@ -16,9 +37,9 @@ local clock_time_format_nil = { "exp-gui_science-production.caption-eta-time", c local long_time_format_nil = long_time_format(nil) --- Remove invalid science packs, this can result from a certain mod not being loaded -for i = #config, 1, -1 do - if not prototypes.item[config[i]] then - table.remove(config, i) +for i = #science_packs, 1, -1 do + if not prototypes.item[science_packs[i]] then + table.remove(science_packs, i) end end @@ -337,7 +358,7 @@ do local _row_data = {} --- @type table ---- @field title string ---- @field body string ---- @field new boolean? ---- @field deleted boolean? - ---- Check if a player can create a new task ---- @param player LuaPlayer ---- @return boolean -local function has_permission_create_task(player) - local allow_add_task = config.allow_add_task - - if allow_add_task == "all" then - return true - elseif allow_add_task == "admin" then - return player.admin - elseif allow_add_task == "exp_roles" then - return Roles.player_has_permission(player, config.exp_roles_allow_add_task) - end - - return false -end - ---- Check if a player can edit an existing task ---- @param player LuaPlayer ---- @param task ExpGui_TaskList.Task ---- @return boolean -local function has_permission_edit_task(player, task) - local allow_edit_task = config.allow_edit_task - - -- Check if editing your own task allows bypassing other permissions - if config.user_can_edit_own_tasks and task.last_user.index == player.index then - return true - end - - -- Check player has permission based on value in the config - if allow_edit_task == "all" then - return true - elseif allow_edit_task == "admin" then - return player.admin - elseif allow_edit_task == "exp_roles" then - return Roles.player_has_permission(player, config.exp_roles_allow_edit_task) - end - - return false -end - ---- @class ExpGui_TaskList.element.new_task_button.elements ---- @field container LuaGuiElement - ---- Button displayed in the header bar, used to add a new task ---- @class ExpGui_TaskList.element.new_task_button: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement -Elements.new_task_button = Gui.define("task_list/new_task_button") - :track_all_elements() - :draw{ - type = "sprite-button", - sprite = "utility/add", - tooltip = { "exp-gui_task-list.tooltip-new" }, - style = "tool_button", - } - :style(Styles.sprite22) - :element_data{ - container = Gui.from_argument(1) - } - :on_click(function(def, player, new_task_button) - --- @cast def ExpGui_TaskList.element.new_task_button - local container = def.data[new_task_button].container - Elements.container.open_edit_task(container, { - id = Elements.container.next_task_id(), - last_edit_tick = game.tick, - last_user = player, - editing = {}, - title = "", - body = "", - new = true, - }) - end) --[[@as any]] - ---- Refresh the visibility based on player permissions ---- @param new_task_button LuaGuiElement -function Elements.new_task_button.refresh(new_task_button) - local player = Gui.get_player(new_task_button) - new_task_button.visible = has_permission_create_task(player) -end - ---- Refresh the visibility based on player permissions ---- @param player LuaPlayer -function Elements.new_task_button.refresh_player(player) - local visible = has_permission_create_task(player) - for _, new_task_button in Elements.new_task_button:tracked_elements(player) do - new_task_button.visible = visible - end -end - ---- @class ExpGui_TaskList.elements.view_task_button.elements ---- @field task ExpGui_TaskList.Task ---- @field container LuaGuiElement - ---- Button used to view a task details, the caption is the task title ---- @class ExpGui_TaskList.element.view_task_button: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, task: ExpGui_TaskList.Task, container: LuaGuiElement): LuaGuiElement -Elements.view_task_button = Gui.define("task_list/view_task_button") - :draw(function(def, parent, task) - --- @cast def ExpGui_TaskList.element.view_task_button - --- @cast task ExpGui_TaskList.Task - return parent.add{ - type = "button", - style = "list_box_item", - caption = task.title, - tooltip = { "exp-gui_task-list.tooltip-last-edit", task.last_user.name, format_time(task.last_edit_tick) }, - } - end) - :style{ - width = 268, - horizontally_stretchable = true, - } - :element_data{ - task = Gui.from_argument(1), - container = Gui.from_argument(2), - } - :on_click(function(def, player, view_task_button) - --- @cast def ExpGui_TaskList.element.view_task_button - local elements = def.data[view_task_button] - Elements.container.open_view_task(elements.container, elements.task) - end) --[[@as any]] - ---- Refresh the title and tooltip of the view task button ---- @param view_task_button LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.view_task_button.refresh(view_task_button, task) - view_task_button.caption = task.title - view_task_button.tooltip = { "exp-gui_task-list.tooltip-last-edit", task.last_user.name, format_time(task.last_edit_tick) } -end - ---- Label used to signal that no tasks are open ---- @class ExpGui_TaskList.elements.no_tasks_header: ExpElement ---- @overload fun(parent: LuaGuiElement): LuaGuiElement -Elements.no_tasks_header = Gui.define("task_list/no_tasks_header") - :track_all_elements() - :draw(function(def, parent, ...) - local subheader = Gui.elements.subframe_base(parent, "negative_subheader_frame") - - local label = subheader.add{ - type = "label", - style = "bold_label", - caption = { "exp-gui_task-list.caption-no-tasks" }, - tooltip = { "exp-gui_task-list.tooltip-no-tasks" }, - } - - label.style.width = 268 - label.style.horizontal_align = "center" - - return subheader - end) - :style{ - padding = { 2, 0 }, - bottom_margin = 0, - } --[[@as any]] - ---- Refresh the visibility of the no tasks label ---- @param no_tasks_header LuaGuiElement -function Elements.no_tasks_header.refresh(no_tasks_header) - local force = Gui.get_player(no_tasks_header).force --[[@as LuaForce]] - no_tasks_header.visible = not Elements.container.has_tasks(force) -end - ---- Refresh the visibility of the no tasks label ---- @param player LuaPlayer -function Elements.no_tasks_header.refresh_player(player) - local visible = not Elements.container.has_tasks(player.force --[[@as LuaForce]]) - for _, no_tasks_header in Elements.no_tasks_header:tracked_elements(player) do - no_tasks_header.visible = visible - end -end - ---- Refresh the visibility of the no tasks label ---- @param force LuaForce -function Elements.no_tasks_header.refresh_force_online(force) - local visible = not Elements.container.has_tasks(force) - for _, no_tasks_header in Elements.no_tasks_header:online_elements(force) do - no_tasks_header.visible = visible - end -end - ---- A table containing all of the current tasks ---- @class ExpGui_TaskList.elements.task_table: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement): LuaGuiElement -Elements.task_list = Gui.define("task_list/task_list") - :draw(function(_, parent) - local task_list = parent.add{ - type = "scroll-pane", - vertical_scroll_policy = "auto", - horizontal_scroll_policy = "never", - style = "scroll_pane_under_subheader", - } - - local task_list_style = task_list.style - task_list_style.horizontally_stretchable = true - task_list_style.maximal_height = 224 - task_list_style.padding = 0 - - -- Cant modify vertical spacing on scroll pane style so need a sub flow - task_list = task_list.add{ type = "flow", direction = "vertical" } - - task_list_style = task_list.style - task_list_style.horizontally_stretchable = true - task_list_style.vertical_spacing = 0 - task_list_style.padding = 0 - - -- Add the no tasks header - local no_tasks_header = Elements.no_tasks_header(task_list) - Elements.no_tasks_header.refresh(no_tasks_header) - - return task_list - end) - :element_data{} --[[@as any]] - ---- Adds a task to the task list ---- @param task_list LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.task_list.add_task(task_list, task) - local container = assert(assert(assert(task_list.parent).parent).parent) - local view_task_buttons = Elements.task_list.data[task_list] - view_task_buttons[task.id] = Elements.view_task_button(task_list, task, container) -end - ---- Remove a task from the task list ---- @param task_list LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.task_list.remove_task(task_list, task) - local view_task_buttons = Elements.task_list.data[task_list] - Gui.destroy_if_valid(view_task_buttons[task.id]) - view_task_buttons[task.id] = nil -end - ---- Refresh a task on the list ---- @param task_list LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.task_list.refresh_task(task_list, task) - local view_task_buttons = Elements.task_list.data[task_list] - Elements.view_task_button.refresh(view_task_buttons[task.id], task) -end - ---- Refresh all tasks on the list ---- @param task_list LuaGuiElement ---- @param tasks ExpGui_TaskList.Task[] -function Elements.task_list.refresh_tasks(task_list, tasks) - local view_task_buttons = Elements.task_list.data[task_list] - local done = {} - - -- Refresh all valid tasks - for _, task in ipairs(tasks) do - done[task.id] = true - local view_task_button = view_task_buttons[task.id] - if view_task_button then - Elements.view_task_button.refresh(view_task_button, task) - else - Elements.task_list.add_task(task_list, task) - end - end - - -- Remove tasks buttons that are no longer required - for id, view_task_button in pairs(view_task_buttons) do - if not done[id] then - view_task_button.destroy() - end - end -end - ---- Select a task button, if nil then all buttons are reset ---- @param task_list LuaGuiElement ---- @param task ExpGui_TaskList.Task? -function Elements.task_list.select_task_button(task_list, task) - local view_task_buttons = Elements.task_list.data[task_list] - local task_button = task and view_task_buttons[task.id] - if task_button and not task_button.enabled then - return - end - for _, view_task_button in pairs(view_task_buttons) do - view_task_button.enabled = true - end - if task_button then - task_button.enabled = false - end -end - ---- The base footer used for view and edit ---- @class ExpGui_TaskList.elements.task_footer: ExpElement ---- @overload fun(parent: LuaGuiElement): LuaGuiElement -Elements.task_footer = Gui.define("task_list/task_footer") - :draw{ - type = "frame", - direction = "vertical", - style = "subfooter_frame", - } - :style{ - height = 0, - padding = 5, - use_header_filler = false, - } --[[@as any]] - ---- @class ExpGui_TaskList.elements.close_task_button.elements ---- @field container LuaGuiElement - ---- Button used to close an open task details ---- @class ExpGui_TaskList.element.close_task_button: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement -Elements.close_task_button = Gui.define("task_list/close_task_button") - :draw{ - type = "sprite-button", - sprite = "utility/collapse", - style = "frame_action_button", - tooltip = { "exp-gui_task-list.tooltip-close" }, - } - :style(Styles.sprite22) - :element_data{ - container = Gui.from_argument(1), - } - :on_click(function(def, player, close_task_button) - --- @cast def ExpGui_TaskList.element.close_task_button - local elements = def.data[close_task_button] - Elements.container.close_footers(elements.container) - end) --[[@as any]] - ---- @class ExpGui_TaskList.elements.task_button.elements ---- @field task ExpGui_TaskList.Task? ---- @field container LuaGuiElement - ---- Button used to edit a task ---- @class ExpGui_TaskList.element.edit_task_button: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement -Elements.edit_task_button = Gui.define("task_list/edit_task_button") - :draw{ - type = "button", - caption = { "exp-gui_task-list.caption-edit" }, - tooltip = { "exp-gui_task-list.tooltip-edit" }, - style = "shortcut_bar_button", - } - :style(Styles.footer_button) - :element_data{ - container = Gui.from_argument(1), - } - :on_click(function(def, player, edit_task_button) - --- @cast def ExpGui_TaskList.element.edit_task_button - local elements = def.data[edit_task_button] - Elements.container.open_edit_task(elements.container, assert(elements.task)) - end) --[[@as any]] - ---- Refresh the edit button ---- @param edit_task_button LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.edit_task_button.refresh(edit_task_button, task) - local player = Gui.get_player(edit_task_button) - Elements.edit_task_button.data[edit_task_button].task = task - edit_task_button.visible = has_permission_edit_task(player, task) - - if next(task.editing) then - local player_names = table.get_values(task.editing) - edit_task_button.tooltip = { "exp-gui_task-list.tooltip-edit", table.concat(player_names, ", ") } - else - edit_task_button.tooltip = { "exp-gui_task-list.tooltip-edit-none" } - end -end - ---- Button used to delete a task ---- @class ExpGui_TaskList.element.delete_task_button: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement -Elements.delete_task_button = Gui.define("task_list/delete_task_button") - :draw{ - type = "button", - caption = { "exp-gui_task-list.caption-delete" }, - tooltip = { "exp-gui_task-list.tooltip-delete" }, - style = "shortcut_bar_button_red", - } - :style(Styles.footer_button) - :element_data{ - container = Gui.from_argument(1), - } - :on_click(function(def, player, delete_task_button) - --- @cast def ExpGui_TaskList.element.delete_task_button - local elements = def.data[delete_task_button] - Elements.container.remove_task(player.force --[[@as LuaForce]], elements.task) - Elements.container.close_footers(elements.container) - end) --[[@as any]] - ---- Refresh the delete button ---- @param delete_task_button LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.delete_task_button.refresh(delete_task_button, task) - local player = Gui.get_player(delete_task_button) - Elements.delete_task_button.data[delete_task_button].task = task - delete_task_button.visible = has_permission_edit_task(player, task) -end - ---- @class ExpGui_TaskList.elements.view_task_footer.elements ---- @field task ExpGui_TaskList.Task? ---- @field body_label LuaGuiElement ---- @field title_label LuaGuiElement ---- @field delete_task_button LuaGuiElement ---- @field edit_task_button LuaGuiElement - ---- The view footer used for view task details ---- @class ExpGui_TaskList.elements.view_task_footer: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement -Elements.view_task_footer = Gui.define("task_list/view_task_footer") - :draw(function(def, parent, container) - --- @cast def ExpGui_TaskList.elements.view_task_footer - local view_task_footer = Elements.task_footer(parent) - - local header_flow = view_task_footer.add{ type = "flow" } - header_flow.add{ - type = "label", - style = "frame_title", - caption = { "exp-gui_task-list.caption-view-footer" }, - } - header_flow.style.right_padding = 1 - header_flow.add{ type = "empty-widget" }.style.horizontally_stretchable = true - Elements.close_task_button(header_flow, container) - - local title_label = view_task_footer.add{ type = "label" } - local title_label_style = title_label.style - title_label_style.font = "default-bold" - title_label_style.single_line = false - title_label_style.padding = 4 - - local body_label = view_task_footer.add{ type = "label" } - body_label.style.single_line = false - body_label.style.padding = 4 - - local action_flow = view_task_footer.add{ type = "flow" } - local delete_task_button = Elements.delete_task_button(action_flow, container) - local edit_task_button = Elements.edit_task_button(action_flow, container) - - def.data[view_task_footer] = { - task = nil, - body_label = body_label, - title_label = title_label, - delete_task_button = delete_task_button, - edit_task_button = edit_task_button, - } - - return view_task_footer - end) --[[@as any]] - ---- Refresh the view task with new task details ---- @param view_task_footer LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.view_task_footer.refresh(view_task_footer, task) - local elements = Elements.view_task_footer.data[view_task_footer] - Elements.delete_task_button.refresh(elements.delete_task_button, task) - Elements.edit_task_button.refresh(elements.edit_task_button, task) - elements.title_label.caption = task.title - elements.body_label.caption = task.body - elements.task = task -end - ---- Refresh the view task with previously selected task ---- If task is provided, then will only update if task is the selected task ---- @param view_task_footer LuaGuiElement ---- @param task ExpGui_TaskList.Task? -function Elements.view_task_footer.update(view_task_footer, task) - local elements = Elements.view_task_footer.data[view_task_footer] - if elements.task and (task == nil or task == elements.task) then - Elements.view_task_footer.refresh(view_task_footer, elements.task) - end -end - ---- Refresh the view task with the previously selected task ---- @param player LuaPlayer -function Elements.view_task_footer.update_player(player) - for _, view_task_footer in Elements.view_task_footer:tracked_elements(player) do - Elements.view_task_footer.update(view_task_footer) - end -end - ---- @class ExpGui_TaskList.elements.task_message_textfield.elements ---- @field confirm_task_button LuaGuiElement? - ---- Textfield element used in both the task create and edit footers ---- @class ExpGui_TaskList.elements.task_message_textfield: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, confirm_task_button: LuaGuiElement?): LuaGuiElement -Elements.task_message_textfield = Gui.define("task_list/task_message_textfield") - :draw{ - type = "text-box", - text = "", - } - :style{ - maximal_width = 268, - minimal_height = 100, - horizontally_stretchable = true, - } - :element_data{ - confirm_task_button = Gui.from_argument(1), - } - :on_text_changed(function(def, player, task_message_textfield) - --- @cast def ExpGui_TaskList.elements.task_message_textfield - local confirm_task_button = assert(def.data[task_message_textfield].confirm_task_button) - confirm_task_button.enabled = string.len(task_message_textfield.text) > 5 - end) --[[@as any]] - ---- Set the confirm task button to update on text changed ---- @param task_message_textfield LuaGuiElement ---- @param confirm_task_button LuaGuiElement -function Elements.task_message_textfield.set_confirm_task_button(task_message_textfield, confirm_task_button) - Elements.task_message_textfield.data[task_message_textfield].confirm_task_button = confirm_task_button -end - ---- Refresh the task message field ---- @param task_message_textfield LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.task_message_textfield.refresh(task_message_textfield, task) - local elements = Elements.task_message_textfield.data[task_message_textfield] - local message = task.new and "" or task.title .. "\n" .. task.body - assert(elements.confirm_task_button).enabled = string.len(message) > 5 - task_message_textfield.text = message - task_message_textfield.focus() -end - ---- @class ExpGui_TaskList.elements.confirm_task_button.elements ---- @field task ExpGui_TaskList.Task? ---- @field task_message_textfield LuaGuiElement ---- @field container LuaGuiElement - ---- Textfield element used in both the task create and edit footers ---- @class ExpGui_TaskList.elements.confirm_task_button: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, task_message_textfield: LuaGuiElement, container: LuaGuiElement): LuaGuiElement -Elements.confirm_task_button = Gui.define("task_list/confirm_task_button") - :draw{ - type = "button", - name = Gui.from_name, - caption = { "exp-gui_task-list.caption-confirm" }, - tooltip = { "exp-gui_task-list.tooltip-confirm" }, - style = "shortcut_bar_button_green", - } - :style(Styles.footer_button) - :element_data{ - task_message_textfield = Gui.from_argument(1), - container = Gui.from_argument(2), - } - :on_click(function(def, player, confirm_task_button) - --- @cast def ExpGui_TaskList.elements.confirm_task_button - local elements = def.data[confirm_task_button] - local task_message_textfield = elements.task_message_textfield - local task = assert(elements.task) - - local parsed = Elements.confirm_task_button.parse(task_message_textfield.text) - task.last_edit_tick = game.tick - task.last_user = player - task.title = parsed.title - task.body = parsed.body - - Elements.container.close_footers(elements.container) - - local force = player.force --[[@as LuaForce]] - if task.new then - Elements.container.add_task(force, task) - else - Elements.container.refresh_force_online(force, task) - end - end) --[[@as any]] - ---- Refresh the confirm task button ---- @param confirm_task_button LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.confirm_task_button.refresh(confirm_task_button, task) - Elements.confirm_task_button.data[confirm_task_button].task = task -end - ---- Parse a task message into its two parts ---- @param message string ---- @return { title: string, body: string } -function Elements.confirm_task_button.parse(message) - -- Trim the spaces of the string - local trimmed = string.gsub(message, "^%s*(.-)%s*$", "%1") - local title, body = string.match(trimmed, "(.-)\n(.*)") - local parsed = { title = title, body = body } --[[@as { title: string, body: string }]] - if not title then - -- If it doesn't match the pattern return the str as a title - parsed.title = trimmed - parsed.body = "" - end - return parsed -end - ---- @class ExpGui_TaskList.elements.discard_task_button.elements ---- @field task ExpGui_TaskList.Task? ---- @field container LuaGuiElement - ---- Button used to close an open task details ---- @class ExpGui_TaskList.element.discard_task_button: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement -Elements.discard_task_button = Gui.define("task_list/discard_task_button") - :draw{ - type = "button", - caption = { "exp-gui_task-list.caption-discard" }, - tooltip = { "exp-gui_task-list.tooltip-discard" }, - style = "shortcut_bar_button_red", - } - :style(Styles.footer_button) - :element_data{ - container = Gui.from_argument(1), - } - :on_click(function(def, player, discard_task_button) - --- @cast def ExpGui_TaskList.element.discard_task_button - local elements = def.data[discard_task_button] - local task = assert(elements.task) - if task.new then - Elements.container.close_footers(elements.container) - else - Elements.container.open_view_task(elements.container, task) - end - end) --[[@as any]] - ---- Refresh the discard task button ---- @param discard_task_button LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.discard_task_button.refresh(discard_task_button, task) - Elements.discard_task_button.data[discard_task_button].task = task -end - ---- @class ExpGui_TaskList.elements.edit_task_footer.elements ---- @field task ExpGui_TaskList.Task? ---- @field header LuaGuiElement ---- @field task_message_textfield LuaGuiElement ---- @field discard_task_button LuaGuiElement ---- @field confirm_task_button LuaGuiElement - ---- The view footer used for view task details ---- @class ExpGui_TaskList.elements.edit_task_footer: ExpElement ---- @field data table ---- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement -Elements.edit_task_footer = Gui.define("task_list/edit_task_footer") - :draw(function(def, parent, container) - --- @cast def ExpGui_TaskList.elements.edit_task_footer - local edit_task_footer = Elements.task_footer(parent) - - local header = edit_task_footer.add{ - type = "label", - style = "frame_title", - caption = { "exp-gui_task-list.caption-edit-footer" }, - } - - local task_message_textfield = Elements.task_message_textfield(edit_task_footer) - - local action_flow = edit_task_footer.add{ type = "flow" } - local discard_task_button = Elements.discard_task_button(action_flow, container) - local confirm_task_button = Elements.confirm_task_button(action_flow, task_message_textfield, container) - Elements.task_message_textfield.set_confirm_task_button(task_message_textfield, confirm_task_button) - - def.data[edit_task_footer] = { - task = nil, - header = header, - task_message_textfield = task_message_textfield, - discard_task_button = discard_task_button, - confirm_task_button = confirm_task_button, - } - - return edit_task_footer - end) --[[@as any]] - ---- Refresh the view task with new task details ---- @param edit_task_footer LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.edit_task_footer.refresh(edit_task_footer, task) - local player = Gui.get_player(edit_task_footer) - local elements = Elements.edit_task_footer.data[edit_task_footer] - task.editing[player.index] = player.name - - if elements.task and elements.task ~= task then - elements.task.editing[player.index] = nil - end - - elements.header.caption = { - task.new and "exp-gui_task-list.caption-create-footer" or "exp-gui_task-list.caption-edit-footer" - } - - Elements.task_message_textfield.refresh(elements.task_message_textfield, task) - Elements.confirm_task_button.refresh(elements.confirm_task_button, task) - Elements.discard_task_button.refresh(elements.discard_task_button, task) - elements.task = task -end - ---- Refresh the view task with previously selected task ---- If task is provided, then will only update if task is the selected task ---- @param edit_task_footer LuaGuiElement ---- @param task ExpGui_TaskList.Task? -function Elements.edit_task_footer.update(edit_task_footer, task) - local elements = Elements.edit_task_footer.data[edit_task_footer] - if elements.task and (task == nil or task == elements.task) then - Elements.edit_task_footer.refresh(edit_task_footer, elements.task) - end -end - ---- Refresh the view task with the previously selected task ---- @param player LuaPlayer -function Elements.edit_task_footer.update_player(player) - for _, edit_task_footer in Elements.edit_task_footer:tracked_elements(player) do - Elements.edit_task_footer.update(edit_task_footer) - end -end - ---- Clear the previously edited task ---- @param edit_task_footer LuaGuiElement -function Elements.edit_task_footer.clear(edit_task_footer) - local elements = Elements.edit_task_footer.data[edit_task_footer] - if elements.task then - local player = Gui.get_player(edit_task_footer) - elements.task.editing[player.index] = nil - elements.task = nil - end -end - ---- @class ExpGui_TaskList.elements.container.elements ---- @field task_list LuaGuiElement ---- @field view_task_footer LuaGuiElement ---- @field edit_task_footer LuaGuiElement - ---- @class ExpGui_TaskList.elements.container.data ---- @field global_data { next_task_id: number } ---- @field [LuaGuiElement] ExpGui_TaskList.elements.container.elements ---- @field [LuaForce] ExpGui_TaskList.Task[] - ---- Container added to the left gui flow ---- @class ExpGui_TaskList.elements.container: ExpElement ---- @field data ExpGui_TaskList.elements.container.data -Elements.container = Gui.define("task_list/container") - :track_all_elements() - :draw(function(def, parent) - --- @cast def ExpGui_TaskList.elements.container - local container = Gui.elements.container(parent) -- width 268 - local root = Gui.elements.container.get_root_element(container) - local elements = {} - - -- Add the header - local header = Gui.elements.header(container, { - caption = { "exp-gui_task-list.caption-main" }, - tooltip = { "exp-gui_task-list.tooltip-sub" }, - }) - - -- Add buttons to the header - local new_task_button = Elements.new_task_button(header, root) - Elements.new_task_button.refresh(new_task_button) - - -- Add the task table and footers - elements.task_list = Elements.task_list(container) - - -- Add tasks to the list if there are any - local player = Gui.get_player(parent) - local force = player.force --[[@as LuaForce]] - local tasks = def.data[force] - if tasks then - for _, task in ipairs(tasks) do - Elements.task_list.add_task(elements.task_list, task) - end - end - - -- Add the footers - elements.view_task_footer = Elements.view_task_footer(container, root) - elements.edit_task_footer = Elements.edit_task_footer(container, root) - elements.view_task_footer.visible = false - elements.edit_task_footer.visible = false - - -- Set the data and return - def.data[root] = elements --[[@as any]] - return root - end) - :global_data{ next_task_id = 1 } - :force_data{} --[[@as any]] - ---- Check if a force has tasks ---- @param force LuaForce ---- @return boolean -function Elements.container.has_tasks(force) - local tasks = Elements.container.data[force] - return tasks and #tasks > 0 -end - ---- Get the next task id ---- @return number -function Elements.container.next_task_id() - local next_task_id = Elements.container.data.global_data.next_task_id - Elements.container.data.global_data.next_task_id = next_task_id + 1 - return next_task_id -end - ---- Add a new task for a force ---- @param force LuaForce ---- @param task ExpGui_TaskList.Task -function Elements.container.add_task(force, task) - local tasks = Elements.container.data[force] - tasks[#tasks + 1] = task - task.deleted = nil - task.new = nil - - Elements.no_tasks_header.refresh_force_online(force) - for _, container in Elements.container:online_elements(force) do - local task_list = Elements.container.data[container].task_list - Elements.task_list.add_task(task_list, task) - end -end - ---- Remove a task from a force ---- @param force LuaForce ---- @param task ExpGui_TaskList.Task -function Elements.container.remove_task(force, task) - local tasks = Elements.container.data[force] - table.remove_element(tasks, task) - task.deleted = true - - Elements.no_tasks_header.refresh_force_online(force) - for _, container in Elements.container:online_elements(force) do - local task_list = Elements.container.data[container].task_list - Elements.task_list.remove_task(task_list, task) - end -end - ---- Open the view footer ---- @param container LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.container.open_view_task(container, task) - local elements = Elements.container.data[container] - Elements.view_task_footer.refresh(elements.view_task_footer, task) - Elements.edit_task_footer.clear(elements.edit_task_footer) - Elements.task_list.select_task_button(elements.task_list, task) - elements.view_task_footer.visible = true - elements.edit_task_footer.visible = false -end - ---- Open the edit footer ---- @param container LuaGuiElement ---- @param task ExpGui_TaskList.Task -function Elements.container.open_edit_task(container, task) - local elements = Elements.container.data[container] - Elements.edit_task_footer.refresh(elements.edit_task_footer, task) - Elements.task_list.select_task_button(elements.task_list, task) - elements.edit_task_footer.visible = true - elements.view_task_footer.visible = false -end - ---- Close the footers ---- @param container LuaGuiElement -function Elements.container.close_footers(container) - local elements = Elements.container.data[container] - Elements.edit_task_footer.clear(elements.edit_task_footer) - Elements.task_list.select_task_button(elements.task_list) - elements.view_task_footer.visible = false - elements.edit_task_footer.visible = false -end - ---- Refresh all tasks for a player ---- @param player LuaPlayer -function Elements.container.refresh_player(player) - local tasks = Elements.container.data[ player.force --[[@as LuaForce]] ] - for _, container in Elements.container:tracked_elements(player) do - local elements = Elements.container.data[container] - Elements.task_list.refresh_tasks(elements.task_list, tasks) - Elements.view_task_footer.update(elements.view_task_footer) - Elements.edit_task_footer.update(elements.edit_task_footer) - end -end - ---- Refresh a tasks for a force ---- @param force LuaForce ---- @param task ExpGui_TaskList.Task -function Elements.container.refresh_force_online(force, task) - for _, container in Elements.container:online_elements(force) do - local elements = Elements.container.data[container] - Elements.task_list.refresh_task(elements.task_list, task) - Elements.view_task_footer.update(elements.view_task_footer, task) - Elements.edit_task_footer.update(elements.edit_task_footer, task) - end -end - ---- Add the element to the left flow with a toolbar button -Gui.add_left_element(Elements.container, function(player) - return Elements.container.has_tasks(player.force --[[@as LuaForce]]) -end) - -Gui.toolbar.create_button{ - name = "toggle_task_list", - left_element = Elements.container, - sprite = "utility/not_enough_repair_packs_icon", - tooltip = { "exp-gui_task-list.tooltip-main" }, - visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.task_list") - end -} - ---- Update the gui when the player joins because it is likely to be outdated ---- @param event EventData.on_player_joined_game -local function refresh_player_tasks(event) - local player = Gui.get_player(event) - Elements.container.refresh_player(player) - Elements.no_tasks_header.refresh_player(player) -end - ---- Update the gui when the player joins because it is likely to be outdated ---- @param event EventData.on_player_joined_game -local function refresh_player_permissions(event) - local player = Gui.get_player(event) - Elements.new_task_button.refresh_player(player) - Elements.view_task_footer.update_player(player) - Elements.edit_task_footer.update_player(player) -end - -local e = defines.events - -return { - elements = Elements, - events = { - [e.on_player_joined_game] = refresh_player_tasks, - [e.on_player_changed_force] = refresh_player_tasks, - [Roles.events.on_player_roles_changed] = refresh_player_permissions, - } -} +--[[-- Gui - Task List +Adds a task list to the game which players can add, remove and edit items on +]] + +local Gui = require("modules/exp_gui") +local Roles = require("modules/exp_roles") +local Feature = require("modules/exp_scenario/features") + +local ExpUtil = require("modules/exp_util") +local format_time = ExpUtil.format_time_factory_locale{ format = "short", hours = true, minutes = true } + +--- @class ExpGui_TaskList.elements +local Elements = {} + +local Styles = { + sprite22 = Gui.styles.sprite{ + size = 22 + }, + footer_button = Gui.styles.sprite{ + height = 29, + maximal_width = 268, + horizontally_stretchable = true, + }, +} + +--- @class ExpGui_TaskList.Task +--- @field id number +--- @field last_user LuaPlayer +--- @field last_edit_tick number +--- @field editing table +--- @field title string +--- @field body string +--- @field new boolean? +--- @field deleted boolean? + +local feature, config = Feature.register("task_list", { + user_can_edit_own_tasks = true, -- the player who made a task can edit it without the edit permission +}) + +--- Check if a player can create a new task +--- @param player LuaPlayer +--- @return boolean +local function has_permission_create_task(player) + return Roles.player_has_permission(player, "exp_scenario.gui.task_list.add") +end + +--- Check if a player can edit an existing task +--- @param player LuaPlayer +--- @param task ExpGui_TaskList.Task +--- @return boolean +local function has_permission_edit_task(player, task) + if config.user_can_edit_own_tasks and task.last_user.index == player.index then + return true + end + return Roles.player_has_permission(player, "exp_scenario.gui.task_list.edit") +end + +--- @class ExpGui_TaskList.element.new_task_button.elements +--- @field container LuaGuiElement + +--- Button displayed in the header bar, used to add a new task +--- @class ExpGui_TaskList.element.new_task_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement +Elements.new_task_button = Gui.define("task_list/new_task_button") + :track_all_elements() + :draw{ + type = "sprite-button", + sprite = "utility/add", + tooltip = { "exp-gui_task-list.tooltip-new" }, + style = "tool_button", + } + :style(Styles.sprite22) + :element_data{ + container = Gui.from_argument(1) + } + :on_click(function(def, player, new_task_button) + --- @cast def ExpGui_TaskList.element.new_task_button + local container = def.data[new_task_button].container + Elements.container.open_edit_task(container, { + id = Elements.container.next_task_id(), + last_edit_tick = game.tick, + last_user = player, + editing = {}, + title = "", + body = "", + new = true, + }) + end) --[[@as any]] + +--- Refresh the visibility based on player permissions +--- @param new_task_button LuaGuiElement +function Elements.new_task_button.refresh(new_task_button) + local player = Gui.get_player(new_task_button) + new_task_button.visible = has_permission_create_task(player) +end + +--- Refresh the visibility based on player permissions +--- @param player LuaPlayer +function Elements.new_task_button.refresh_player(player) + local visible = has_permission_create_task(player) + for _, new_task_button in Elements.new_task_button:tracked_elements(player) do + new_task_button.visible = visible + end +end + +--- @class ExpGui_TaskList.elements.view_task_button.elements +--- @field task ExpGui_TaskList.Task +--- @field container LuaGuiElement + +--- Button used to view a task details, the caption is the task title +--- @class ExpGui_TaskList.element.view_task_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, task: ExpGui_TaskList.Task, container: LuaGuiElement): LuaGuiElement +Elements.view_task_button = Gui.define("task_list/view_task_button") + :draw(function(def, parent, task) + --- @cast def ExpGui_TaskList.element.view_task_button + --- @cast task ExpGui_TaskList.Task + return parent.add{ + type = "button", + style = "list_box_item", + caption = task.title, + tooltip = { "exp-gui_task-list.tooltip-last-edit", task.last_user.name, format_time(task.last_edit_tick) }, + } + end) + :style{ + width = 268, + horizontally_stretchable = true, + } + :element_data{ + task = Gui.from_argument(1), + container = Gui.from_argument(2), + } + :on_click(function(def, player, view_task_button) + --- @cast def ExpGui_TaskList.element.view_task_button + local elements = def.data[view_task_button] + Elements.container.open_view_task(elements.container, elements.task) + end) --[[@as any]] + +--- Refresh the title and tooltip of the view task button +--- @param view_task_button LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.view_task_button.refresh(view_task_button, task) + view_task_button.caption = task.title + view_task_button.tooltip = { "exp-gui_task-list.tooltip-last-edit", task.last_user.name, format_time(task.last_edit_tick) } +end + +--- Label used to signal that no tasks are open +--- @class ExpGui_TaskList.elements.no_tasks_header: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.no_tasks_header = Gui.define("task_list/no_tasks_header") + :track_all_elements() + :draw(function(def, parent, ...) + local subheader = Gui.elements.subframe_base(parent, "negative_subheader_frame") + + local label = subheader.add{ + type = "label", + style = "bold_label", + caption = { "exp-gui_task-list.caption-no-tasks" }, + tooltip = { "exp-gui_task-list.tooltip-no-tasks" }, + } + + label.style.width = 268 + label.style.horizontal_align = "center" + + return subheader + end) + :style{ + padding = { 2, 0 }, + bottom_margin = 0, + } --[[@as any]] + +--- Refresh the visibility of the no tasks label +--- @param no_tasks_header LuaGuiElement +function Elements.no_tasks_header.refresh(no_tasks_header) + local force = Gui.get_player(no_tasks_header).force --[[@as LuaForce]] + no_tasks_header.visible = not Elements.container.has_tasks(force) +end + +--- Refresh the visibility of the no tasks label +--- @param player LuaPlayer +function Elements.no_tasks_header.refresh_player(player) + local visible = not Elements.container.has_tasks(player.force --[[@as LuaForce]]) + for _, no_tasks_header in Elements.no_tasks_header:tracked_elements(player) do + no_tasks_header.visible = visible + end +end + +--- Refresh the visibility of the no tasks label +--- @param force LuaForce +function Elements.no_tasks_header.refresh_force_online(force) + local visible = not Elements.container.has_tasks(force) + for _, no_tasks_header in Elements.no_tasks_header:online_elements(force) do + no_tasks_header.visible = visible + end +end + +--- A table containing all of the current tasks +--- @class ExpGui_TaskList.elements.task_table: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.task_list = Gui.define("task_list/task_list") + :draw(function(_, parent) + local task_list = parent.add{ + type = "scroll-pane", + vertical_scroll_policy = "auto", + horizontal_scroll_policy = "never", + style = "scroll_pane_under_subheader", + } + + local task_list_style = task_list.style + task_list_style.horizontally_stretchable = true + task_list_style.maximal_height = 224 + task_list_style.padding = 0 + + -- Cant modify vertical spacing on scroll pane style so need a sub flow + task_list = task_list.add{ type = "flow", direction = "vertical" } + + task_list_style = task_list.style + task_list_style.horizontally_stretchable = true + task_list_style.vertical_spacing = 0 + task_list_style.padding = 0 + + -- Add the no tasks header + local no_tasks_header = Elements.no_tasks_header(task_list) + Elements.no_tasks_header.refresh(no_tasks_header) + + return task_list + end) + :element_data{} --[[@as any]] + +--- Adds a task to the task list +--- @param task_list LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.task_list.add_task(task_list, task) + local container = assert(assert(assert(task_list.parent).parent).parent) + local view_task_buttons = Elements.task_list.data[task_list] + view_task_buttons[task.id] = Elements.view_task_button(task_list, task, container) +end + +--- Remove a task from the task list +--- @param task_list LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.task_list.remove_task(task_list, task) + local view_task_buttons = Elements.task_list.data[task_list] + Gui.destroy_if_valid(view_task_buttons[task.id]) + view_task_buttons[task.id] = nil +end + +--- Refresh a task on the list +--- @param task_list LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.task_list.refresh_task(task_list, task) + local view_task_buttons = Elements.task_list.data[task_list] + Elements.view_task_button.refresh(view_task_buttons[task.id], task) +end + +--- Refresh all tasks on the list +--- @param task_list LuaGuiElement +--- @param tasks ExpGui_TaskList.Task[] +function Elements.task_list.refresh_tasks(task_list, tasks) + local view_task_buttons = Elements.task_list.data[task_list] + local done = {} + + -- Refresh all valid tasks + for _, task in ipairs(tasks) do + done[task.id] = true + local view_task_button = view_task_buttons[task.id] + if view_task_button then + Elements.view_task_button.refresh(view_task_button, task) + else + Elements.task_list.add_task(task_list, task) + end + end + + -- Remove tasks buttons that are no longer required + for id, view_task_button in pairs(view_task_buttons) do + if not done[id] then + view_task_button.destroy() + end + end +end + +--- Select a task button, if nil then all buttons are reset +--- @param task_list LuaGuiElement +--- @param task ExpGui_TaskList.Task? +function Elements.task_list.select_task_button(task_list, task) + local view_task_buttons = Elements.task_list.data[task_list] + local task_button = task and view_task_buttons[task.id] + if task_button and not task_button.enabled then + return + end + for _, view_task_button in pairs(view_task_buttons) do + view_task_button.enabled = true + end + if task_button then + task_button.enabled = false + end +end + +--- The base footer used for view and edit +--- @class ExpGui_TaskList.elements.task_footer: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.task_footer = Gui.define("task_list/task_footer") + :draw{ + type = "frame", + direction = "vertical", + style = "subfooter_frame", + } + :style{ + height = 0, + padding = 5, + use_header_filler = false, + } --[[@as any]] + +--- @class ExpGui_TaskList.elements.close_task_button.elements +--- @field container LuaGuiElement + +--- Button used to close an open task details +--- @class ExpGui_TaskList.element.close_task_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement +Elements.close_task_button = Gui.define("task_list/close_task_button") + :draw{ + type = "sprite-button", + sprite = "utility/collapse", + style = "frame_action_button", + tooltip = { "exp-gui_task-list.tooltip-close" }, + } + :style(Styles.sprite22) + :element_data{ + container = Gui.from_argument(1), + } + :on_click(function(def, player, close_task_button) + --- @cast def ExpGui_TaskList.element.close_task_button + local elements = def.data[close_task_button] + Elements.container.close_footers(elements.container) + end) --[[@as any]] + +--- @class ExpGui_TaskList.elements.task_button.elements +--- @field task ExpGui_TaskList.Task? +--- @field container LuaGuiElement + +--- Button used to edit a task +--- @class ExpGui_TaskList.element.edit_task_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement +Elements.edit_task_button = Gui.define("task_list/edit_task_button") + :draw{ + type = "button", + caption = { "exp-gui_task-list.caption-edit" }, + tooltip = { "exp-gui_task-list.tooltip-edit" }, + style = "shortcut_bar_button", + } + :style(Styles.footer_button) + :element_data{ + container = Gui.from_argument(1), + } + :on_click(function(def, player, edit_task_button) + --- @cast def ExpGui_TaskList.element.edit_task_button + local elements = def.data[edit_task_button] + Elements.container.open_edit_task(elements.container, assert(elements.task)) + end) --[[@as any]] + +--- Refresh the edit button +--- @param edit_task_button LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.edit_task_button.refresh(edit_task_button, task) + local player = Gui.get_player(edit_task_button) + Elements.edit_task_button.data[edit_task_button].task = task + edit_task_button.visible = has_permission_edit_task(player, task) + + if next(task.editing) then + local player_names = table.get_values(task.editing) + edit_task_button.tooltip = { "exp-gui_task-list.tooltip-edit", table.concat(player_names, ", ") } + else + edit_task_button.tooltip = { "exp-gui_task-list.tooltip-edit-none" } + end +end + +--- Button used to delete a task +--- @class ExpGui_TaskList.element.delete_task_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement +Elements.delete_task_button = Gui.define("task_list/delete_task_button") + :draw{ + type = "button", + caption = { "exp-gui_task-list.caption-delete" }, + tooltip = { "exp-gui_task-list.tooltip-delete" }, + style = "shortcut_bar_button_red", + } + :style(Styles.footer_button) + :element_data{ + container = Gui.from_argument(1), + } + :on_click(function(def, player, delete_task_button) + --- @cast def ExpGui_TaskList.element.delete_task_button + local elements = def.data[delete_task_button] + Elements.container.remove_task(player.force --[[@as LuaForce]], elements.task) + Elements.container.close_footers(elements.container) + end) --[[@as any]] + +--- Refresh the delete button +--- @param delete_task_button LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.delete_task_button.refresh(delete_task_button, task) + local player = Gui.get_player(delete_task_button) + Elements.delete_task_button.data[delete_task_button].task = task + delete_task_button.visible = has_permission_edit_task(player, task) +end + +--- @class ExpGui_TaskList.elements.view_task_footer.elements +--- @field task ExpGui_TaskList.Task? +--- @field body_label LuaGuiElement +--- @field title_label LuaGuiElement +--- @field delete_task_button LuaGuiElement +--- @field edit_task_button LuaGuiElement + +--- The view footer used for view task details +--- @class ExpGui_TaskList.elements.view_task_footer: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement +Elements.view_task_footer = Gui.define("task_list/view_task_footer") + :draw(function(def, parent, container) + --- @cast def ExpGui_TaskList.elements.view_task_footer + local view_task_footer = Elements.task_footer(parent) + + local header_flow = view_task_footer.add{ type = "flow" } + header_flow.add{ + type = "label", + style = "frame_title", + caption = { "exp-gui_task-list.caption-view-footer" }, + } + header_flow.style.right_padding = 1 + header_flow.add{ type = "empty-widget" }.style.horizontally_stretchable = true + Elements.close_task_button(header_flow, container) + + local title_label = view_task_footer.add{ type = "label" } + local title_label_style = title_label.style + title_label_style.font = "default-bold" + title_label_style.single_line = false + title_label_style.padding = 4 + + local body_label = view_task_footer.add{ type = "label" } + body_label.style.single_line = false + body_label.style.padding = 4 + + local action_flow = view_task_footer.add{ type = "flow" } + local delete_task_button = Elements.delete_task_button(action_flow, container) + local edit_task_button = Elements.edit_task_button(action_flow, container) + + def.data[view_task_footer] = { + task = nil, + body_label = body_label, + title_label = title_label, + delete_task_button = delete_task_button, + edit_task_button = edit_task_button, + } + + return view_task_footer + end) --[[@as any]] + +--- Refresh the view task with new task details +--- @param view_task_footer LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.view_task_footer.refresh(view_task_footer, task) + local elements = Elements.view_task_footer.data[view_task_footer] + Elements.delete_task_button.refresh(elements.delete_task_button, task) + Elements.edit_task_button.refresh(elements.edit_task_button, task) + elements.title_label.caption = task.title + elements.body_label.caption = task.body + elements.task = task +end + +--- Refresh the view task with previously selected task +--- If task is provided, then will only update if task is the selected task +--- @param view_task_footer LuaGuiElement +--- @param task ExpGui_TaskList.Task? +function Elements.view_task_footer.update(view_task_footer, task) + local elements = Elements.view_task_footer.data[view_task_footer] + if elements.task and (task == nil or task == elements.task) then + Elements.view_task_footer.refresh(view_task_footer, elements.task) + end +end + +--- Refresh the view task with the previously selected task +--- @param player LuaPlayer +function Elements.view_task_footer.update_player(player) + for _, view_task_footer in Elements.view_task_footer:tracked_elements(player) do + Elements.view_task_footer.update(view_task_footer) + end +end + +--- @class ExpGui_TaskList.elements.task_message_textfield.elements +--- @field confirm_task_button LuaGuiElement? + +--- Textfield element used in both the task create and edit footers +--- @class ExpGui_TaskList.elements.task_message_textfield: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, confirm_task_button: LuaGuiElement?): LuaGuiElement +Elements.task_message_textfield = Gui.define("task_list/task_message_textfield") + :draw{ + type = "text-box", + text = "", + } + :style{ + maximal_width = 268, + minimal_height = 100, + horizontally_stretchable = true, + } + :element_data{ + confirm_task_button = Gui.from_argument(1), + } + :on_text_changed(function(def, player, task_message_textfield) + --- @cast def ExpGui_TaskList.elements.task_message_textfield + local confirm_task_button = assert(def.data[task_message_textfield].confirm_task_button) + confirm_task_button.enabled = string.len(task_message_textfield.text) > 5 + end) --[[@as any]] + +--- Set the confirm task button to update on text changed +--- @param task_message_textfield LuaGuiElement +--- @param confirm_task_button LuaGuiElement +function Elements.task_message_textfield.set_confirm_task_button(task_message_textfield, confirm_task_button) + Elements.task_message_textfield.data[task_message_textfield].confirm_task_button = confirm_task_button +end + +--- Refresh the task message field +--- @param task_message_textfield LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.task_message_textfield.refresh(task_message_textfield, task) + local elements = Elements.task_message_textfield.data[task_message_textfield] + local message = task.new and "" or task.title .. "\n" .. task.body + assert(elements.confirm_task_button).enabled = string.len(message) > 5 + task_message_textfield.text = message + task_message_textfield.focus() +end + +--- @class ExpGui_TaskList.elements.confirm_task_button.elements +--- @field task ExpGui_TaskList.Task? +--- @field task_message_textfield LuaGuiElement +--- @field container LuaGuiElement + +--- Textfield element used in both the task create and edit footers +--- @class ExpGui_TaskList.elements.confirm_task_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, task_message_textfield: LuaGuiElement, container: LuaGuiElement): LuaGuiElement +Elements.confirm_task_button = Gui.define("task_list/confirm_task_button") + :draw{ + type = "button", + name = Gui.from_name, + caption = { "exp-gui_task-list.caption-confirm" }, + tooltip = { "exp-gui_task-list.tooltip-confirm" }, + style = "shortcut_bar_button_green", + } + :style(Styles.footer_button) + :element_data{ + task_message_textfield = Gui.from_argument(1), + container = Gui.from_argument(2), + } + :on_click(function(def, player, confirm_task_button) + --- @cast def ExpGui_TaskList.elements.confirm_task_button + local elements = def.data[confirm_task_button] + local task_message_textfield = elements.task_message_textfield + local task = assert(elements.task) + + local parsed = Elements.confirm_task_button.parse(task_message_textfield.text) + task.last_edit_tick = game.tick + task.last_user = player + task.title = parsed.title + task.body = parsed.body + + Elements.container.close_footers(elements.container) + + local force = player.force --[[@as LuaForce]] + if task.new then + Elements.container.add_task(force, task) + else + Elements.container.refresh_force_online(force, task) + end + end) --[[@as any]] + +--- Refresh the confirm task button +--- @param confirm_task_button LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.confirm_task_button.refresh(confirm_task_button, task) + Elements.confirm_task_button.data[confirm_task_button].task = task +end + +--- Parse a task message into its two parts +--- @param message string +--- @return { title: string, body: string } +function Elements.confirm_task_button.parse(message) + -- Trim the spaces of the string + local trimmed = string.gsub(message, "^%s*(.-)%s*$", "%1") + local title, body = string.match(trimmed, "(.-)\n(.*)") + local parsed = { title = title, body = body } --[[@as { title: string, body: string }]] + if not title then + -- If it doesn't match the pattern return the str as a title + parsed.title = trimmed + parsed.body = "" + end + return parsed +end + +--- @class ExpGui_TaskList.elements.discard_task_button.elements +--- @field task ExpGui_TaskList.Task? +--- @field container LuaGuiElement + +--- Button used to close an open task details +--- @class ExpGui_TaskList.element.discard_task_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement +Elements.discard_task_button = Gui.define("task_list/discard_task_button") + :draw{ + type = "button", + caption = { "exp-gui_task-list.caption-discard" }, + tooltip = { "exp-gui_task-list.tooltip-discard" }, + style = "shortcut_bar_button_red", + } + :style(Styles.footer_button) + :element_data{ + container = Gui.from_argument(1), + } + :on_click(function(def, player, discard_task_button) + --- @cast def ExpGui_TaskList.element.discard_task_button + local elements = def.data[discard_task_button] + local task = assert(elements.task) + if task.new then + Elements.container.close_footers(elements.container) + else + Elements.container.open_view_task(elements.container, task) + end + end) --[[@as any]] + +--- Refresh the discard task button +--- @param discard_task_button LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.discard_task_button.refresh(discard_task_button, task) + Elements.discard_task_button.data[discard_task_button].task = task +end + +--- @class ExpGui_TaskList.elements.edit_task_footer.elements +--- @field task ExpGui_TaskList.Task? +--- @field header LuaGuiElement +--- @field task_message_textfield LuaGuiElement +--- @field discard_task_button LuaGuiElement +--- @field confirm_task_button LuaGuiElement + +--- The view footer used for view task details +--- @class ExpGui_TaskList.elements.edit_task_footer: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, container: LuaGuiElement): LuaGuiElement +Elements.edit_task_footer = Gui.define("task_list/edit_task_footer") + :draw(function(def, parent, container) + --- @cast def ExpGui_TaskList.elements.edit_task_footer + local edit_task_footer = Elements.task_footer(parent) + + local header = edit_task_footer.add{ + type = "label", + style = "frame_title", + caption = { "exp-gui_task-list.caption-edit-footer" }, + } + + local task_message_textfield = Elements.task_message_textfield(edit_task_footer) + + local action_flow = edit_task_footer.add{ type = "flow" } + local discard_task_button = Elements.discard_task_button(action_flow, container) + local confirm_task_button = Elements.confirm_task_button(action_flow, task_message_textfield, container) + Elements.task_message_textfield.set_confirm_task_button(task_message_textfield, confirm_task_button) + + def.data[edit_task_footer] = { + task = nil, + header = header, + task_message_textfield = task_message_textfield, + discard_task_button = discard_task_button, + confirm_task_button = confirm_task_button, + } + + return edit_task_footer + end) --[[@as any]] + +--- Refresh the view task with new task details +--- @param edit_task_footer LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.edit_task_footer.refresh(edit_task_footer, task) + local player = Gui.get_player(edit_task_footer) + local elements = Elements.edit_task_footer.data[edit_task_footer] + task.editing[player.index] = player.name + + if elements.task and elements.task ~= task then + elements.task.editing[player.index] = nil + end + + elements.header.caption = { + task.new and "exp-gui_task-list.caption-create-footer" or "exp-gui_task-list.caption-edit-footer" + } + + Elements.task_message_textfield.refresh(elements.task_message_textfield, task) + Elements.confirm_task_button.refresh(elements.confirm_task_button, task) + Elements.discard_task_button.refresh(elements.discard_task_button, task) + elements.task = task +end + +--- Refresh the view task with previously selected task +--- If task is provided, then will only update if task is the selected task +--- @param edit_task_footer LuaGuiElement +--- @param task ExpGui_TaskList.Task? +function Elements.edit_task_footer.update(edit_task_footer, task) + local elements = Elements.edit_task_footer.data[edit_task_footer] + if elements.task and (task == nil or task == elements.task) then + Elements.edit_task_footer.refresh(edit_task_footer, elements.task) + end +end + +--- Refresh the view task with the previously selected task +--- @param player LuaPlayer +function Elements.edit_task_footer.update_player(player) + for _, edit_task_footer in Elements.edit_task_footer:tracked_elements(player) do + Elements.edit_task_footer.update(edit_task_footer) + end +end + +--- Clear the previously edited task +--- @param edit_task_footer LuaGuiElement +function Elements.edit_task_footer.clear(edit_task_footer) + local elements = Elements.edit_task_footer.data[edit_task_footer] + if elements.task then + local player = Gui.get_player(edit_task_footer) + elements.task.editing[player.index] = nil + elements.task = nil + end +end + +--- @class ExpGui_TaskList.elements.container.elements +--- @field task_list LuaGuiElement +--- @field view_task_footer LuaGuiElement +--- @field edit_task_footer LuaGuiElement + +--- @class ExpGui_TaskList.elements.container.data +--- @field global_data { next_task_id: number } +--- @field [LuaGuiElement] ExpGui_TaskList.elements.container.elements +--- @field [LuaForce] ExpGui_TaskList.Task[] + +--- Container added to the left gui flow +--- @class ExpGui_TaskList.elements.container: ExpElement +--- @field data ExpGui_TaskList.elements.container.data +Elements.container = Gui.define("task_list/container") + :track_all_elements() + :draw(function(def, parent) + --- @cast def ExpGui_TaskList.elements.container + local container = Gui.elements.container(parent) -- width 268 + local root = Gui.elements.container.get_root_element(container) + local elements = {} + + -- Add the header + local header = Gui.elements.header(container, { + caption = { "exp-gui_task-list.caption-main" }, + tooltip = { "exp-gui_task-list.tooltip-sub" }, + }) + + -- Add buttons to the header + local new_task_button = Elements.new_task_button(header, root) + Elements.new_task_button.refresh(new_task_button) + + -- Add the task table and footers + elements.task_list = Elements.task_list(container) + + -- Add tasks to the list if there are any + local player = Gui.get_player(parent) + local force = player.force --[[@as LuaForce]] + local tasks = def.data[force] + if tasks then + for _, task in ipairs(tasks) do + Elements.task_list.add_task(elements.task_list, task) + end + end + + -- Add the footers + elements.view_task_footer = Elements.view_task_footer(container, root) + elements.edit_task_footer = Elements.edit_task_footer(container, root) + elements.view_task_footer.visible = false + elements.edit_task_footer.visible = false + + -- Set the data and return + def.data[root] = elements --[[@as any]] + return root + end) + :global_data{ next_task_id = 1 } + :force_data{} --[[@as any]] + +--- Check if a force has tasks +--- @param force LuaForce +--- @return boolean +function Elements.container.has_tasks(force) + local tasks = Elements.container.data[force] + return tasks and #tasks > 0 +end + +--- Get the next task id +--- @return number +function Elements.container.next_task_id() + local next_task_id = Elements.container.data.global_data.next_task_id + Elements.container.data.global_data.next_task_id = next_task_id + 1 + return next_task_id +end + +--- Add a new task for a force +--- @param force LuaForce +--- @param task ExpGui_TaskList.Task +function Elements.container.add_task(force, task) + local tasks = Elements.container.data[force] + tasks[#tasks + 1] = task + task.deleted = nil + task.new = nil + + Elements.no_tasks_header.refresh_force_online(force) + for _, container in Elements.container:online_elements(force) do + local task_list = Elements.container.data[container].task_list + Elements.task_list.add_task(task_list, task) + end +end + +--- Remove a task from a force +--- @param force LuaForce +--- @param task ExpGui_TaskList.Task +function Elements.container.remove_task(force, task) + local tasks = Elements.container.data[force] + table.remove_element(tasks, task) + task.deleted = true + + Elements.no_tasks_header.refresh_force_online(force) + for _, container in Elements.container:online_elements(force) do + local task_list = Elements.container.data[container].task_list + Elements.task_list.remove_task(task_list, task) + end +end + +--- Open the view footer +--- @param container LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.container.open_view_task(container, task) + local elements = Elements.container.data[container] + Elements.view_task_footer.refresh(elements.view_task_footer, task) + Elements.edit_task_footer.clear(elements.edit_task_footer) + Elements.task_list.select_task_button(elements.task_list, task) + elements.view_task_footer.visible = true + elements.edit_task_footer.visible = false +end + +--- Open the edit footer +--- @param container LuaGuiElement +--- @param task ExpGui_TaskList.Task +function Elements.container.open_edit_task(container, task) + local elements = Elements.container.data[container] + Elements.edit_task_footer.refresh(elements.edit_task_footer, task) + Elements.task_list.select_task_button(elements.task_list, task) + elements.edit_task_footer.visible = true + elements.view_task_footer.visible = false +end + +--- Close the footers +--- @param container LuaGuiElement +function Elements.container.close_footers(container) + local elements = Elements.container.data[container] + Elements.edit_task_footer.clear(elements.edit_task_footer) + Elements.task_list.select_task_button(elements.task_list) + elements.view_task_footer.visible = false + elements.edit_task_footer.visible = false +end + +--- Refresh all tasks for a player +--- @param player LuaPlayer +function Elements.container.refresh_player(player) + local tasks = Elements.container.data[ player.force --[[@as LuaForce]] ] + for _, container in Elements.container:tracked_elements(player) do + local elements = Elements.container.data[container] + Elements.task_list.refresh_tasks(elements.task_list, tasks) + Elements.view_task_footer.update(elements.view_task_footer) + Elements.edit_task_footer.update(elements.edit_task_footer) + end +end + +--- Refresh a tasks for a force +--- @param force LuaForce +--- @param task ExpGui_TaskList.Task +function Elements.container.refresh_force_online(force, task) + for _, container in Elements.container:online_elements(force) do + local elements = Elements.container.data[container] + Elements.task_list.refresh_task(elements.task_list, task) + Elements.view_task_footer.update(elements.view_task_footer, task) + Elements.edit_task_footer.update(elements.edit_task_footer, task) + end +end + +--- Add the element to the left flow with a toolbar button +Gui.add_left_element(Elements.container, function(player) + return Elements.container.has_tasks(player.force --[[@as LuaForce]]) +end) + +Gui.toolbar.create_button{ + name = "toggle_task_list", + left_element = Elements.container, + sprite = "utility/not_enough_repair_packs_icon", + tooltip = { "exp-gui_task-list.tooltip-main" }, + visible = function(player, element) + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.task_list") + end +} + +--- Update the gui when the player joins because it is likely to be outdated +--- @param event EventData.on_player_joined_game +local function refresh_player_tasks(event) + local player = Gui.get_player(event) + Elements.container.refresh_player(player) + Elements.no_tasks_header.refresh_player(player) +end + +--- Update the gui when the player joins because it is likely to be outdated +--- @param event EventData.on_player_joined_game +local function refresh_player_permissions(event) + local player = Gui.get_player(event) + Elements.new_task_button.refresh_player(player) + Elements.view_task_footer.update_player(player) + Elements.edit_task_footer.update_player(player) +end + +local e = defines.events + +return feature:guard{ + elements = Elements, + events = { + [e.on_player_joined_game] = refresh_player_tasks, + [e.on_player_changed_force] = refresh_player_tasks, + [Roles.events.on_player_roles_changed] = refresh_player_permissions, + } +} diff --git a/exp_scenario/module/gui/vlayer.lua b/exp_scenario/module/gui/vlayer.lua index b5bea951..1295ea92 100644 --- a/exp_scenario/module/gui/vlayer.lua +++ b/exp_scenario/module/gui/vlayer.lua @@ -5,8 +5,8 @@ Shows what the virtual layer holds and lets players build and remove its interfa local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") local Colors = require("modules/exp_util/include/color") -local config = require("modules.exp_legacy.config.vlayer") local Vlayer = require("modules/exp_scenario/control/vlayer") +local feature, config = Vlayer.feature, Vlayer.config local format_number = require("util").format_number local Selection = require("modules/exp_util/selection") @@ -444,7 +444,7 @@ Gui.toolbar.create_button{ sprite = "entity/solar-panel", tooltip = { "exp-gui_vlayer.tooltip-main" }, visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.vlayer") + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.vlayer") end } @@ -520,12 +520,12 @@ local function refresh_player_permissions(event) Elements.controls.refresh_permissions(Gui.get_player(event)) end -return { +return feature:guard{ elements = Elements, events = { [Roles.events.on_player_roles_changed] = refresh_player_permissions, }, on_nth_tick = { - [config.update_tick_gui] = Elements.stats_table.refresh_online, + [60] = Elements.stats_table.refresh_online, } } diff --git a/exp_scenario/module/gui/warp_list.lua b/exp_scenario/module/gui/warp_list.lua index f76faed4..d7ac0574 100644 --- a/exp_scenario/module/gui/warp_list.lua +++ b/exp_scenario/module/gui/warp_list.lua @@ -5,7 +5,7 @@ Adds a list of warp points which players can travel between, add, and edit local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") local Colors = require("modules/exp_util/include/color") -local config = require("modules.exp_legacy.config.gui.warps") +local Feature = require("modules/exp_scenario/features") local ExpUtil = require("modules/exp_util") local format_time = ExpUtil.format_time_factory_locale{ format = "short", hours = true, minutes = true } @@ -31,18 +31,61 @@ local status_icons = { different = "[img=utility/warning_white]", } -local update_interval = floor(60 / config.update_smoothing) -local cooldown_ticks = config.cooldown_duration * 60 -local proximity_radius_sq = config.standard_proximity_radius ^ 2 -local spawn_radius_sq = config.spawn_proximity_radius ^ 2 -local minimum_distance_sq = config.minimum_distance ^ 2 +local feature, config = Feature.register("warp_list", { + minimum_distance = 100, -- the minimum distance that is allowed between warps on the same force + cooldown_duration = 60, -- the duration of the warp cooldown in seconds + standard_proximity_radius = 4, -- the minimum distance a player is allowed to be to a warp in order to use it + spawn_proximity_radius = 20, -- the minimum distance a player is allowed to be from their spawn point to use warps + user_can_edit_own_warps = false, -- the player who made a warp can edit it without the edit permission +}) + +--- Ticks between updates of the cooldown timers +local update_interval = 6 + +--- The default icon of new warps +local default_icon = { type = "item", name = "discharge-defense-equipment" } + +--- The entities which are created for warp areas { name, x, y } +local area_entities = { + { "small-lamp", -4, -2 }, { "small-lamp", -2, -4 }, { "medium-electric-pole", -3, -3 }, -- Top left corner + { "small-lamp", 3, -2 }, { "small-lamp", 1, -4 }, { "medium-electric-pole", 2, -3 }, -- Top right corner + { "small-lamp", 3, 1 }, { "small-lamp", 1, 3 }, { "medium-electric-pole", 2, 2 }, -- Bottom right corner + { "small-lamp", -4, 1 }, { "small-lamp", -2, 3 }, { "medium-electric-pole", -3, 2 }, -- Bottom left corner + +} + +--- The tiles which are created for warp areas { name, x, y } +local area_tiles = { + { "black-refined-concrete", -4, -2 }, { "black-refined-concrete", -4, -1 }, { "black-refined-concrete", -4, 0 }, { "black-refined-concrete", -4, 1 }, + { "black-refined-concrete", -3, -3 }, { "purple-refined-concrete", -3, -2 }, { "purple-refined-concrete", -3, -1 }, { "purple-refined-concrete", -3, 0 }, + { "purple-refined-concrete", -3, 1 }, { "black-refined-concrete", -3, 2 }, { "black-refined-concrete", -2, -4 }, { "purple-refined-concrete", -2, -3 }, + { "purple-refined-concrete", -2, -2 }, { "purple-refined-concrete", -2, -1 }, { "purple-refined-concrete", -2, 0 }, { "purple-refined-concrete", -2, 1 }, + { "purple-refined-concrete", -2, 2 }, { "black-refined-concrete", -2, 3 }, { "black-refined-concrete", -1, -4 }, { "purple-refined-concrete", -1, -3 }, + { "purple-refined-concrete", -1, -2 }, { "purple-refined-concrete", -1, -1 }, { "purple-refined-concrete", -1, 0 }, { "purple-refined-concrete", -1, 1 }, + { "purple-refined-concrete", -1, 2 }, { "black-refined-concrete", -1, 3 }, { "black-refined-concrete", 0, -4 }, { "purple-refined-concrete", 0, -3 }, + { "purple-refined-concrete", 0, -2 }, { "purple-refined-concrete", 0, -1 }, { "purple-refined-concrete", 0, 0 }, { "purple-refined-concrete", 0, 1 }, + { "purple-refined-concrete", 0, 2 }, { "black-refined-concrete", 0, 3 }, { "black-refined-concrete", 1, -4 }, { "purple-refined-concrete", 1, -3 }, + { "purple-refined-concrete", 1, -2 }, { "purple-refined-concrete", 1, -1 }, { "purple-refined-concrete", 1, 0 }, { "purple-refined-concrete", 1, 1 }, + { "purple-refined-concrete", 1, 2 }, { "black-refined-concrete", 1, 3 }, { "black-refined-concrete", 2, -3 }, { "purple-refined-concrete", 2, -2 }, + { "purple-refined-concrete", 2, -1 }, { "purple-refined-concrete", 2, 0 }, { "purple-refined-concrete", 2, 1 }, { "black-refined-concrete", 2, 2 }, + { "black-refined-concrete", 3, -2 }, { "black-refined-concrete", 3, -1 }, { "black-refined-concrete", 3, 0 }, { "black-refined-concrete", 3, 1 }, + +} --- Names of the entities which make up a warp area, used to find them when the area is removed local area_entity_names = {} --- @type string[] -for _, entity in ipairs(config.entities) do +for _, entity in ipairs(area_entities) do area_entity_names[#area_entity_names + 1] = entity[1] end +--- The role permission each action needs +local permissions = { + bypass_warp_cooldown = "exp_scenario.gui.warp_list.bypass_cooldown", + bypass_warp_proximity = "exp_scenario.gui.warp_list.bypass_proximity", + allow_add_warp = "exp_scenario.gui.warp_list.add", + allow_edit_warp = "exp_scenario.gui.warp_list.edit", +} + --- @class ExpGui_WarpList.Warp --- @field id number --- @field force LuaForce @@ -63,21 +106,11 @@ end --- @field on_cooldown boolean --- @field bypass_proximity boolean ---- Check a permission from the config, which says who an action applies to --- @param player LuaPlayer --- @param action "bypass_warp_cooldown" | "bypass_warp_proximity" | "allow_add_warp" | "allow_edit_warp" --- @return boolean local function has_config_permission(player, action) - local setting = config[action] - if setting == "all" then - return true - elseif setting == "admin" then - return player.admin - elseif setting == "exp_roles" then - return Roles.player_has_permission(player, config["exp_roles_" .. action]) - end - - return false + return Roles.player_has_permission(player, permissions[action]) end --- Check if a player can edit a warp, the spawn warp can never be edited @@ -694,7 +727,7 @@ Elements.cooldown_bar = Gui.define("warp_list/cooldown_bar") --- @param cooldown number function Elements.cooldown_bar.refresh(cooldown_bar, cooldown) if cooldown > 0 then - cooldown_bar.value = 1 - cooldown / cooldown_ticks + cooldown_bar.value = 1 - cooldown / (config.cooldown_duration * 60) cooldown_bar.tooltip = { "exp-gui_warp-list.tooltip-cooldown", ceil(cooldown / 60) } else cooldown_bar.value = 1 @@ -877,12 +910,12 @@ function Elements.container._make_area(warp) warp.old_tile = surface.get_tile(position.x, position.y).name local tiles = {} - for index, tile in ipairs(config.tiles) do + for index, tile in ipairs(area_tiles) do tiles[index] = { name = tile[1], position = { x = tile[2] + position.x, y = tile[3] + position.y } } end surface.set_tiles(tiles) - for _, entity_config in ipairs(config.entities) do + for _, entity_config in ipairs(area_entities) do local entity = assert(surface.create_entity{ name = entity_config[1], position = { x = entity_config[2] + position.x, y = entity_config[3] + position.y }, @@ -908,7 +941,7 @@ function Elements.container._remove_area(warp) local surface = warp.surface local position = warp.position local tiles = {} - for index, tile in ipairs(config.tiles) do + for index, tile in ipairs(area_tiles) do tiles[index] = { name = old_tile, position = { x = tile[2] + position.x, y = tile[3] + position.y } } end surface.set_tiles(tiles) @@ -947,7 +980,7 @@ function Elements.container.add_warp(force, surface, position, player, name) id = id, force = force, name = name or "New warp", - icon = { type = config.default_icon.type, name = config.default_icon.name }, + icon = { type = default_icon.type, name = default_icon.name }, surface = surface, position = { x = floor(position.x), y = floor(position.y) }, last_user = player, @@ -1054,8 +1087,8 @@ function Elements.container.teleport_player(player, warp) local player_data = Elements.container._get_player_data(player) if not has_config_permission(player, "bypass_warp_cooldown") then - player_data.cooldown = cooldown_ticks - Elements.cooldown_bar.refresh_player(player, cooldown_ticks) + player_data.cooldown = config.cooldown_duration * 60 + Elements.cooldown_bar.refresh_player(player, player_data.cooldown) end player_data.in_range = warp Elements.warp_table.refresh_status_player(player) @@ -1109,7 +1142,7 @@ function Elements.container._update_proximity(player, player_data) end local in_range = nil --- @type ExpGui_WarpList.Warp? - if closest and closest_distance < (closest == force_data.spawn and spawn_radius_sq or proximity_radius_sq) then + if closest and closest_distance < (closest == force_data.spawn and config.spawn_proximity_radius or config.standard_proximity_radius) ^ 2 then in_range = closest end if in_range ~= player_data.in_range then @@ -1121,7 +1154,7 @@ function Elements.container._update_proximity(player, player_data) end local blocking = nil --- @type ExpGui_WarpList.Warp? - if closest and closest_distance <= minimum_distance_sq then + if closest and closest_distance <= config.minimum_distance ^ 2 then blocking = closest end if blocking ~= player_data.blocking then @@ -1135,10 +1168,10 @@ Gui.add_left_element(Elements.container, false) Gui.toolbar.create_button{ name = "toggle_warp_list", left_element = Elements.container, - sprite = config.default_icon.type .. "/" .. config.default_icon.name, + sprite = default_icon.type .. "/" .. default_icon.name, tooltip = { "exp-gui_warp-list.tooltip-main" }, visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.warp_list") + return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.warp_list") end }:on_click(function(def, player) -- A list the player opened themselves stays open when they leave a warp @@ -1202,7 +1235,7 @@ end local e = defines.events -return { +return feature:guard{ elements = Elements, events = { [e.on_player_created] = on_player_created, diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 4d3fe9db..1917b254 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -1,6 +1,9 @@ [exp-commands-authorities_role] deny=Unauthorized, Access is denied due to missing permissions. +[exp-commands-authorities_feature] +deny=This command is disabled on this server. + [exp-commands-parse_role] lower-role=Role is higher than your highest. lower-role-player=Player has a higher role. diff --git a/exp_scenario/module/module.json b/exp_scenario/module/module.json index 34082135..2b2d40e9 100644 --- a/exp_scenario/module/module.json +++ b/exp_scenario/module/module.json @@ -3,6 +3,7 @@ "load": [ ], "require": [ + "globals.lua", "control.lua" ], "dependencies": { diff --git a/exp_scenario/package.json b/exp_scenario/package.json index 706fef28..191b13be 100644 --- a/exp_scenario/package.json +++ b/exp_scenario/package.json @@ -17,15 +17,18 @@ }, "peerDependencies": { "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^" }, "devDependencies": { "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", "@types/node": "catalog:", "@types/react": "catalog:", "antd": "catalog:", + "fengari": "^0.1.5", "react": "catalog:", "react-dom": "catalog:", "tap": "^21.1.0", diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index befc28df..ba3d8c41 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -4,6 +4,7 @@ import { Controller } from "@clusterio/controller"; import { ControllerPlugin } from "../dist/node/controller.js"; import * as messages from "../dist/node/messages.js"; import { seedRoles, seedGroups } from "../dist/node/seed.js"; +import { features } from "../dist/node/features.js"; import * as roles from "@expcluster/roles"; import * as groups from "@expcluster/permission-groups"; import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js"; @@ -16,7 +17,7 @@ import { plugin } from "../dist/node/index.js"; lib.registerPluginPermissions([plugin]); // The controller validates message classes against the link registry -for (const Message of [messages.SeedRequest, ...roles.plugin.messages, ...groups.plugin.messages]) { +for (const Message of [...plugin.messages, ...roles.plugin.messages, ...groups.plugin.messages]) { lib.Link.register(Message); } @@ -29,9 +30,9 @@ t.after(() => { }); /** Build the plugin around a real controller, which is side effect free while not started. */ -async function startPlugin(t2, { withPlugins = true } = {}) { +async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.testdir() } = {}) { const controllerConfig = new lib.ControllerConfig("controller", { - "controller.database_directory": t2.testdir(), + "controller.database_directory": databaseDirectory, "controller.default_role_id": lib.Role.DefaultPlayerRoleId, }); const controller = new Controller(logger, [], controllerConfig); @@ -40,13 +41,11 @@ async function startPlugin(t2, { withPlugins = true } = {}) { if (withPlugins) { for (const [name, Plugin] of [["exp_roles", RolesPlugin], ["exp_groups", GroupsPlugin]]) { - const plugin = new Plugin({ plugin: { name }, controller, logger }); - await plugin.init(); + await Plugin.fromContext({ plugin: { name }, controller, logger }); } } - const plugin = new ControllerPlugin({ plugin: { name: "exp_scenario" }, controller, logger }); - await plugin.init(); + const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_scenario" }, controller, logger }); return { plugin, controller }; } @@ -135,5 +134,61 @@ t.test("class ControllerPlugin", t2 => { t3.notOk([...first.values()].filter(priority => priority === 2).length > 1, "seed priorities skip taken ones"); }); + t2.test(".fromContext() 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(".fromContext() keeps stored features across restarts", async t3 => { + const databaseDirectory = t3.testdir(); + const { plugin } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + await plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 })); + await plugin.onShutdown(); + + const { plugin: restarted } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + const record = restarted.features.get("afk_kick"); + t3.strictSame([record.enabled, record.values], [false, { afk_minutes: 5 }], "the stored record is loaded"); + }); + t2.end(); }); diff --git a/exp_scenario/test/features.test.js b/exp_scenario/test/features.test.js new file mode 100644 index 00000000..928e855f --- /dev/null +++ b/exp_scenario/test/features.test.js @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import path from "node:path"; +import t from "tap"; +import { features, pruneFeatureValues, validateFeatureValues } from "../dist/node/features.js"; + +/** Every lua file in the module, by path relative to it. */ +function luaFiles(dir, prefix = "") { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const relative = path.posix.join(prefix, entry.name); + if (entry.isDirectory()) { + return luaFiles(path.join(dir, entry.name), relative); + } + return entry.name.endsWith(".lua") ? [[relative, fs.readFileSync(path.join(dir, entry.name), "utf8")]] : []; + }); +} + +const moduleFiles = luaFiles(path.join(import.meta.dirname, "..", "module")); + +/** + * Read a default from a Feature.register call, a path of "a.b" looks for b after the table a starts. + * Only covers booleans, numbers, strings, Feature.list, Feature.set, and Feature.optional. + */ +function luaDefault(source, path) { + const keys = path.split("."); + let start = 0; + for (const section of keys.slice(0, -1)) { + const match = new RegExp(`^\\s*${section} = \\{`, "m").exec(source.slice(start)); + if (!match) { + return undefined; + } + start += match.index + match[0].length; + } + + const key = keys.at(-1); + const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Feature\\.optional\\("\\w+"\\)|(?:Feature\\.(?:list|set))?\\{[^{}]*\\})`, "m") + .exec(source.slice(start)); + if (!match) { + return undefined; + } + const literal = match[1]; + if (literal.startsWith("Feature.optional")) { + return null; + } + if (literal.includes("{")) { + return [...literal.matchAll(/"([^"]*)"/g)].map(item => item[1]); + } + return JSON.parse(literal); +} + +t.test("features", t2 => { + for (const feature of features) { + t2.test(feature.name, t3 => { + const declarations = moduleFiles.filter(([, source]) => ( + source.includes(`Feature.register("${feature.name}"`) + )); + t3.equal(declarations.length, 1, "one lua file declares the feature"); + const source = declarations[0]?.[1] ?? ""; + for (const field of feature.fields) { + t3.strictSame(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`); + } + t3.end(); + }); + } + t2.end(); +}); + +t.test("validateFeatureValues()", t2 => { + t2.same( + validateFeatureValues("death_markers", { show_map_markers: false, collect_corpses: true }), + { show_map_markers: false }, + "values equal to their default are dropped", + ); + t2.throws(() => validateFeatureValues("unknown", {}), { message: "Unknown feature unknown" }); + t2.throws( + () => validateFeatureValues("death_markers", { map_icon: "x" }), + { message: "Feature death_markers has no setting map_icon" }, + ); + t2.throws( + () => validateFeatureValues("death_markers", { show_map_markers: "no" }), + { message: "Setting show_map_markers of death_markers must be a boolean" }, + ); + t2.throws( + () => validateFeatureValues("afk_kick", { afk_minutes: 0 }), + { message: "Setting afk_minutes of afk_kick must be at least 1" }, + ); + t2.throws( + () => validateFeatureValues("afk_kick", { afk_minutes: Infinity }), + { message: "Setting afk_minutes of afk_kick must be a finite number" }, + ); + t2.throws( + () => validateFeatureValues("protection", { always_protected_types: ["boiler", 1] }), + { message: "Setting always_protected_types of protection must be a list of strings" }, + ); + t2.throws( + () => validateFeatureValues("afk_kick", { afk_minutes: null }), + { message: "Setting afk_minutes of afk_kick can not be null" }, + ); + t2.same( + validateFeatureValues("protection", { always_protected_types: ["boiler"], always_trigger_repeat_types: ["reactor", "fusion-reactor", "rocket-silo"] }), + { always_protected_types: ["boiler"] }, + "lists equal to their default are dropped", + ); + t2.same(validateFeatureValues("afk_kick", { active_role_id: null }), {}, "no role is the default"); + t2.same(validateFeatureValues("afk_kick", { active_role_id: 3 }), { active_role_id: 3 }); + t2.same(validateFeatureValues("spawn_area", { "turrets.enabled": false }), { "turrets.enabled": false }, "nested settings"); + t2.end(); +}); + +t.test("pruneFeatureValues()", t2 => { + const afkKick = features.find(feature => feature.name === "afk_kick"); + t2.same( + pruneFeatureValues(afkKick, { afk_minutes: 5, trust_time: 600, kick_minutes: "soon", active_role_id: null }), + { kept: { afk_minutes: 5, active_role_id: null }, dropped: ["trust_time", "kick_minutes"] }, + "removed settings and wrong types are dropped, valid values and optional nulls stay", + ); + t2.end(); +}); diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js new file mode 100644 index 00000000..22f59639 --- /dev/null +++ b/exp_scenario/test/instance.test.js @@ -0,0 +1,123 @@ +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 = await InstancePlugin.fromContext({ plugin: { name: "exp_scenario" }, instance, host: {}, logger }); + return { plugin, instance, state }; +} + +/** The payload of a recorded rcon command. */ +function decodeRcon(command) { + const match = command.match(/^\/sc exp_scenario\.features\.receive_update\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s); + return JSON.parse(match[2]); +} + +t.test("class InstancePlugin", t2 => { + t2.test(".onStart() subscribes and sends every feature to lua", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.onStart(); + + t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first"); + t3.ok(state.sent[1] instanceof messages.FeatureListRequest, "then lists the features"); + const [update] = decodeRcon(state.rcons[0]); + const deathMarkers = features.find(feature => feature.name === "death_markers"); + t3.equal(update.name, "death_markers"); + t3.strictSame([update.values.enabled, update.values.show_map_markers], [false, false], "stored values are sent"); + t3.equal(update.values.collect_corpses, true, "the rest are filled with their defaults"); + t3.equal(Object.keys(update.values).length, deathMarkers.fields.length + 1, "every field and enabled"); + }); + + t2.test(".handleFeatureUpdatedEvent() only sends while the game is up", async t3 => { + const { plugin, state } = await startPlugin(t3); + const event = new messages.FeatureUpdatedEvent([ + new messages.FeatureRecord("afk_kick", true, { afk_minutes: 5 }), + new messages.FeatureRecord("removed", true, {}, 0, true), + ]); + + await plugin.handleFeatureUpdatedEvent(event); + t3.strictSame(state.rcons, [], "nothing is sent before start"); + + await plugin.onStart(); + state.rcons.length = 0; + await plugin.handleFeatureUpdatedEvent(event); + const updates = decodeRcon(state.rcons[0]); + t3.strictSame(updates.map(update => update.name), ["afk_kick"], "deleted features are left out"); + t3.strictSame([updates[0].values.enabled, updates[0].values.afk_minutes], [true, 5]); + + await plugin.onExit(); + state.rcons.length = 0; + await plugin.handleFeatureUpdatedEvent(event); + t3.strictSame(state.rcons, [], "nothing is sent after exit"); + }); + + t2.test(".onControllerConnectionEvent() syncs again after the controller restarts", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.onControllerConnectionEvent("connect"); + t3.strictSame(state.sent, [], "nothing happens before start"); + + await plugin.onStart(); + state.sent.length = 0; + state.rcons.length = 0; + await plugin.onControllerConnectionEvent("resume"); + t3.strictSame(state.sent, [], "a resumed session keeps its subscription"); + + await plugin.onControllerConnectionEvent("connect"); + t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again"); + t3.equal(state.rcons.length, 1, "and sends every feature"); + }); + + t2.test(".luaSendFeatures() picks a long bracket the values do not close", async t3 => { + const { plugin, state } = await startPlugin(t3); + const name = "a]=]b]==]c"; + await plugin.luaSendFeatures([new messages.FeatureRecord("station_auto_name", true, { station_name: name })]); + t3.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json"); + t3.strictSame(decodeRcon(state.rcons[0])[0].values.station_name, name); + }); + + t2.end(); +}); diff --git a/exp_scenario/test/module.test.js b/exp_scenario/test/module.test.js new file mode 100644 index 00000000..0aa3fde7 --- /dev/null +++ b/exp_scenario/test/module.test.js @@ -0,0 +1,8 @@ +import path from "node:path"; +import t from "tap"; +import { reportLuaTests } from "../../test/lua/runner.js"; + +const envFile = path.join(import.meta.dirname, "module", "env.lua"); + +// Each file runs in its own lua state, and each test in a fresh environment +t.test("features.lua", t2 => reportLuaTests(t2, envFile, path.join(import.meta.dirname, "module", "features.lua"))); diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua new file mode 100644 index 00000000..96c41456 --- /dev/null +++ b/exp_scenario/test/module/env.lua @@ -0,0 +1,55 @@ +--[[-- Test environment for module/features.lua +Extends the shared stubs with a fresh copy of the features module and a stub +of exp_util storage which lets tests act out on_init and on_load. + +Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite +returned here becomes `...` in each test file. +]] + +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source:gsub("\\", "/") +local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$")) + +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework + +--- @class ExpScenario.TestEnv : Stubs +--- @field Feature ExpScenario.Features A fresh copy of the features module +--- @field storage table The table registered with storage, as on_init would store it +--- @field on_load fun(tbl: table) Act out on_load with the given storage table +--- @field load_config fun(name: string): ExpScenario.Feature Load module/config/.lua +--- @field logged string[] Messages passed to log +--- @field events table[] Raised events, name and tick filled in + +return Framework.suite(function(env) + --- @cast env ExpScenario.TestEnv + local callback --- @type fun(tbl: table) + env.logged = {} + log = function(message) env.logged[#env.logged + 1] = message end + table_size = function(tbl) + local size = 0 + for _ in pairs(tbl) do size = size + 1 end + return size + end + env.extend_requires{ + ["modules/exp_util/storage"] = { + register = function(tbl, fn) + env.storage = tbl + callback = fn + end, + }, + } + + env.Feature = assert(loadfile(plugin_root .. "/module/features.lua"))() + env.extend_requires{ ["modules/exp_scenario/features"] = env.Feature } + + function env.on_load(tbl) + env.storage = tbl + callback(tbl) + end + + function env.load_config(name) + return assert(loadfile(plugin_root .. "/module/config/" .. name .. ".lua"))() + end + + return env +end) diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua new file mode 100644 index 00000000..37284577 --- /dev/null +++ b/exp_scenario/test/module/features.lua @@ -0,0 +1,152 @@ +local Suite = ... --- @type Suite + +--- The raised on_config_updated events as { path, old value, new value }, sorted by path +local function updates(env) + local rtn = {} + for _, event in ipairs(env.events) do + rtn[#rtn + 1] = { event.path, event.old_value, event.new_value } + end + table.sort(rtn, function(a, b) return a[1] < b[1] end) + return rtn +end + +Suite.test("register() enables features unless the config says otherwise", function(env) + Suite.eq(env.Feature.register("on", { value = 1 }).config.enabled, true, "enabled by default") + Suite.eq(env.Feature.register("off", { enabled = false }):is_enabled(), false, "the config can disable it") + Suite.eq(env.Feature.register("empty").config, { enabled = true }, "config is optional") + Suite.throws(function() env.Feature.register("on", {}) end, "Feature already registered: on", "names are unique") +end) + +Suite.test("register() returns the config as well", function(env) + local feature, config = env.Feature.register("test", { flag = true }) + Suite.eq(config == feature.config, true, "the same table") +end) + +Suite.test("get() finds registered features", function(env) + local feature = env.Feature.register("test") + Suite.eq(env.Feature.get("test") == feature, true, "by name") + Suite.throws(function() env.Feature.get("unknown") end, "Unknown feature: unknown", "unknown names error") +end) + +Suite.test("update_config() sets every value, missing ones to their default", function(env) + local feature, config = env.Feature.register("test", { flag = true, count = 5, text = "a" }) + feature:update_config{ enabled = false, flag = false, count = 7 } + Suite.eq(config, { enabled = false, flag = false, count = 7, text = "a" }, "given values are set") + + feature:update_config{ enabled = true, text = "b" } + Suite.eq(config, { enabled = true, flag = true, count = 5, text = "b" }, "values left out go back to their default") + Suite.eq(env.storage, { test = { enabled = true, text = "b" } }, "the values are kept in storage") +end) + +Suite.test("update_config() raises on_config_updated for each changed value", function(env) + local feature = env.Feature.register("test", { flag = true, names = env.Feature.set{ "a" }, section = { count = 1 } }) + feature:update_config{ enabled = false, flag = true, names = { "a" }, ["section.count"] = 2 } + Suite.eq(env.Feature.events.on_config_updated, assert(env.events[1]).name, "the event is on_config_updated") + Suite.eq(updates(env), { + { "enabled", true, false }, + { "section.count", 1, 2 }, + }, "with the path, old value, and new value, unchanged values raise nothing") +end) + +Suite.test("update_config() raises when a value goes back to its default", function(env) + local feature = env.Feature.register("test", { flag = true }) + feature:update_config{ enabled = true, flag = false } + env.events = {} + feature:update_config{ enabled = true } + Suite.eq(updates(env), { { "flag", false, true } }, "the value reverts") +end) + +Suite.test("update_config() ignores and logs values of the wrong type", function(env) + local function check() return true end + local feature, config = env.Feature.register("test", { flag = true, check = check }) + feature:update_config{ enabled = true, flag = "yes", check = false } + Suite.eq(config, { enabled = true, flag = true, check = check }, "wrong types and functions keep their default") + Suite.eq(#env.logged, 2, "each one is logged") +end) + +Suite.test("receive_update() updates known features", function(env) + local feature = env.Feature.register("test", { flag = true }) + env.Feature.receive_update{ + { name = "test", values = { enabled = true, flag = false } }, + { name = "unknown", values = { enabled = false } }, + } + Suite.eq(feature.config.flag, false, "the known feature changed") + Suite.eq(env.storage.unknown, nil, "unknown features are not stored") +end) + +Suite.test("on_load applies the stored values without raising events", function(env) + local feature, config = env.Feature.register("test", { flag = true }) + env.on_load{ test = { enabled = false, flag = false, gone = 1 } } + Suite.eq(config, { enabled = false, flag = false }, "the saved values apply, unknown ones are skipped") + Suite.empty(env.events, "nothing is raised") + + feature:update_config{ enabled = true } + Suite.eq(env.storage, { test = { enabled = true } }, "later updates replace the stored values") +end) + +Suite.test("guard() skips handlers while the feature is disabled", function(env) + local feature = env.Feature.register("test") + local calls = {} + local lib = feature:guard{ + events = { on_event = function(event) calls[#calls + 1] = event end }, + on_nth_tick = { [60] = function(event) calls[#calls + 1] = event end }, + on_init = function() end, + } + + lib.events.on_event("a") + lib.on_nth_tick[60]("b") + feature:update_config{ enabled = false } + lib.events.on_event("c") + lib.on_nth_tick[60]("d") + Suite.eq(calls, { "a", "b" }, "only called while enabled") +end) + +Suite.test("register() addresses nested values by path", function(env) + local offset = { 1, 2 } + local feature, config = env.Feature.register("test", { section = { flag = true, count = 1 }, offset = offset }) + feature:update_config{ enabled = true, ["section.flag"] = false, offset = { "x" } } + Suite.eq(config.section, { flag = false, count = 1 }, "the nested value changes in place") + Suite.eq(config.offset, offset, "tables which are not sections can not be set") + + feature:update_config{ enabled = true } + Suite.eq(config.section.flag, true, "and goes back to its default") +end) + +Suite.test("list() declares a list of strings", function(env) + local feature, config = env.Feature.register("test", { names = env.Feature.list{ "a", "b" }, empty = env.Feature.list{}, plain = { "x" } }) + Suite.eq(config.names, { "a", "b" }, "the default is the list") + feature:update_config{ enabled = true, names = { "c" }, empty = { "d" }, plain = { "y" } } + Suite.eq(config.names, { "c" }, "a list is replaced") + Suite.eq(config.empty, { "d" }, "an empty list can be set") + Suite.eq(config.plain, { "x" }, "a plain table can not be set") + feature:update_config{ enabled = true, names = { 1 } } + Suite.eq(config.names, { "a", "b" }, "a list with other types is invalid") +end) + +Suite.test("set() declares a set of strings which is sent as a list", function(env) + local feature, config = env.Feature.register("test", { names = env.Feature.set{ "a" }, empty = env.Feature.set{} }) + Suite.eq(config.names, { a = true }, "the default is a set") + Suite.eq(config.empty, {}, "and can be empty") + feature:update_config{ enabled = true, names = { "b", "c" } } + Suite.eq(config.names, { b = true, c = true }, "the list is converted to a set") + env.on_load{ test = { enabled = true, names = { "d" } } } + Suite.eq(config.names, { d = true }, "also when the values are loaded") +end) + +Suite.test("optional() declares a value without a default", function(env) + local feature, config = env.Feature.register("test", { role_id = env.Feature.optional("number") }) + Suite.eq(config.role_id, nil, "nil until set") + feature:update_config{ enabled = true, role_id = 3 } + Suite.eq(config.role_id, 3, "set with the given type") + feature:update_config{ enabled = true } + Suite.eq(config.role_id, nil, "and back to nil") +end) + +Suite.test("config files register their feature", function(env) + local feature = env.load_config("spawn_area") + feature:update_config{ enabled = true, ["turrets.enabled"] = false } + Suite.eq(feature.config.turrets.enabled, false, "spawn_area can be changed") + Suite.eq(feature.config.turrets.ammo_type, "uranium-rounds-magazine", "other values keep their default") +end) + +return Suite.run() diff --git a/exp_scenario/tsconfig.browser.json b/exp_scenario/tsconfig.browser.json index b67577c5..c60f963b 100644 --- a/exp_scenario/tsconfig.browser.json +++ b/exp_scenario/tsconfig.browser.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.browser.json", - "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "permissions.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "features.ts", "permissions.ts", "package.json" ], } diff --git a/exp_scenario/web/components/FeaturesPage.tsx b/exp_scenario/web/components/FeaturesPage.tsx new file mode 100644 index 00000000..1ef64f77 --- /dev/null +++ b/exp_scenario/web/components/FeaturesPage.tsx @@ -0,0 +1,349 @@ +import React, { useContext, useState } from "react"; +import { Button, Checkbox, Col, ConfigProvider, Divider, Input, InputNumber, Row, Select, Space, Spin, Switch } from "antd"; + +import * as lib from "@clusterio/lib"; +import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount } from "@clusterio/web_ui"; + +import { Feature, FeatureField, FeatureValue, features, isDefaultValue, sameValue } from "../../features.js"; +import { FeatureRecord, FeatureUpdateRequest } from "../../messages.js"; +import { useFeatures } from "../index"; + +type FeatureState = { enabled: boolean, values: Record }; +type PendingChanges = Record }>; + +// Same colours as the role and permission group pages +const borderColor = "#424242"; +const modifiedColor = "#2a1912"; + +/** The stored state of a feature, with defaults for values which were not set. */ +function storedState(feature: Feature, record: FeatureRecord | undefined): FeatureState { + return { + enabled: record?.enabled ?? true, + values: { + ...Object.fromEntries(feature.fields.map(field => [field.name, field.default])), + ...record?.values, + }, + }; +} + +/** Group fields by the section in their name, "turrets.enabled" is in the "turrets" section. */ +function groupFields(fields: FeatureField[]) { + const groups = new Map(); + for (const field of fields) { + const section = field.name.includes(".") ? field.name.slice(0, field.name.lastIndexOf(".")) : ""; + if (!groups.has(section)) { + groups.set(section, []); + } + groups.get(section)!.push(field); + } + return [...groups.entries()]; +} + +/** "afk_belts" to "Afk Belts", like the group titles on the role page */ +function formatSectionTitle(name: string) { + return name + .replace(/\./g, " / ") + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +function matches(text: string, query: string) { + return text.toLowerCase().includes(query); +} + +/** Fields matching the search, every field when the feature itself matches */ +function searchFields(feature: Feature, query: string) { + if (!query || matches(feature.title, query) || matches(feature.description, query) || matches(feature.name, query)) { + return feature.fields; + } + return feature.fields.filter(field => ( + matches(field.title, query) || matches(field.description, query) || matches(field.name, query) + )); +} + +/** Renders a setting like the config pages do, registered input components take priority */ +function FieldInput(props: { field: FeatureField, value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) { + const { field, value, disabled, onChange } = props; + const control = useContext(ControlContext); + if (field.type !== "string_list" && field.inputComponent) { + const CustomInput = control.inputComponents.get(field.inputComponent); + if (CustomInput) { + const fieldDefinition: lib.FieldDefinition = { + type: field.type, + title: field.title, + description: field.description, + optional: field.optional, + inputComponent: field.inputComponent, + }; + return } + onChange={onChange} + disabled={disabled} + />; + } + } + + switch (field.type) { + case "number": + return onChange(next ?? (field.optional ? null : field.default))} + />; + case "string": + if (field.enum) { + return onChange(e.target.value)} style={{ width: 300 }} />; + case "string_list": + return