Helpful Examples for Modding
Introduction
Covers helpful examples of complex topics within Eco modding. Provides example code snippets.
Here's a project file structure that covers all the basic functionality:
MyProject ├─ Plugin.cs ├─ Logger.cs ├─ ChatCommands.cs └─ Registration.cs
Plugin.cs
Here's the structure to start with:
namespace MyProject
{
[SupportedOSPlatform("windows7.0")]
public class MyProjectPlugin
{
// Used with chat commands and for release tracking
public const string VERSION = "v0.0.0";
}
}
[SupportedOSPlatform("windows7.0")] is here to silence some annoying warnings. It's optional.
VERSION is defined here because it's the main code and when that code gets an update the version number should change too.
This example is using semantic versioning. It uses 3 numbers: major version, minor version, and patch version. It can be useful to help communicate how big of an update is made. For example, v2.0.0 is a whole major version away from v1.0.0. That indicates a lot of changes. But something like v1.0.1 is only a small patch away from v1.0.0. That signals something very trivial has changed. To get a better understanding Google "semantic versioning" and read more.
With all that said, v1, v2, v3... v1058. Is perfectly valid way of versioning too!
IModKitPlugin
This is what makes Eco's code include this mods code into the game. Generally, this is implemented only once per mod.
using Eco.Core.Plugins.Interfaces;
using Eco.Shared.Localization;
namespace MyProject
{
[SupportedOSPlatform("windows7.0")]
public class MyProjectPlugin : IModKitPlugin
{
// Used with chat commands and for release tracking
public const string VERSION = "v0.0.0";
#region IModKitPlugin
public string GetCategory() => "Mods";
private string _status = "Created...";
public string GetStatus() => Localizer.DoStr($"Status: {_status}");
#endregion
}
}
Line 4: AddedIModKitPluginto the class.
The #region IModKiPlugin and #endregion is optional and just let's the editor know how to collapse code that is related.
The two fields that this interface needs are GetCategory() and GetStatus().
Line 13: Some mod author's prefer to set the category to their mod in specific. For example, "MyProject" instead of the general purpose "Mods". This value will affect where it shows up on the Server GUI.
Line 15-16: _status is defined as private to only allow this class to alter it's status. See sections on IInitializablePlugin and IShutdownablePlugin to see usage.
This interface on it's own doesn't do much. It just gets Eco to pickup on the mod and inject it into the runtime.
IInitializablePlugin and IShutdownablePlugin
These two interfaces aren't required to be implemented at the same time, but often are. That's because Eco is very event-driven. Meaning often mods are waiting and listening for an event to happen before doing something. To do that listening part, the mod must register and release itself as to that event.
One example of a method used to listen to game actions is ActionUtil.AddListener(). This method will be used and explained in the further section on IGameActionAware below. For now, the point is that method needs called once on mod startup and once on mod shutdown. Another example would be listening to store's offers changing with StoreItemData.SellOffersChangedEvent.AddUnique() which let's the mod respond to trade offers changing. While this store even won't be covered here, it's being mentioned to show how there are many different event sources in Eco's code. If the mod registers as a listener, then it needs to remove itself as a listener.
The Initialize function is the first "hook" this mod gets to run code. From here you can access different services and inspect the world. For example, to get a list of all stores:
var stores = WorldObjectManager.GetWorldObjectsFromComponent(typeof(StoreComponent));
Outlining all the managers and services in Eco is beyond this page's scope, but the point is to demonstrate that at this point in the code the game is largely loaded and running.
using Eco.Core.Plugins.Interfaces;
using Eco.Core.Utils;
using Eco.Gameplay.Components.Store.Internal;
using Eco.Gameplay.GameActions;
using Eco.Shared.Localization;
namespace MyProject
{
[SupportedOSPlatform("windows7.0")]
[Priority(PriorityAttribute.Normal)] // VeryHigh, High, Normal, Low, VeryLow
public class MyProjectPlugin: IModKitPlugin, IInitializablePlugin, IShutdownablePlugin
{
#region IModKitPlugin
#region IInitializablePlugin
public void Initialize(TimedTask timer)
{
_status = "Initializing...";
// Example Listeners:
// StoreItemData.SellOffersChangedEvent.AddUnique(HandleOffersChanged);
// ActionUtil.AddListener(this);
//
// To see when this function is called try something like and run the game:
// Logger.Debug("Initializing MyProject")
_status = "Running";
}
#endregion
#region IShutdownablePlugin
public Task ShutdownAsync()
{
_status = "Stopping...";
// Example Cleanup:
//StoreItemData.SellOffersChangedEvent.Remove(HandleOffersChanged);
//ActionUtil.RemoveListener(this);
//
// To see when this function is called try something like and run the game:
// Logger.Debug("Cleaning Up MyProject")
_status = "Stopped";
return Task.CompletedTask;
}
#endregion
}
}
Line 10: This attribute sets the mod's load priority. It controls when the mod is loaded in relation to other mods. PriorityAttribute.VeryHigh is for things that need to start-up first and stop last. It's not recommended to use this priority unless you know what you are doing. PriorityAttribute.VeryLow will do the opposite. It will try to load your mod last and stop it first. Useful if you want to wait for other mods to load in and make their changes first.
Line 11: Added IInitializablePlugin and IShutdownablePlugin to the class.
Line 14: Notice how TimedTask timer isn't being used. That's because for most mods are quick to initialize and don't need to report back on their progress. That's what this timer does. It's a way for the mod to say "Still loading... 50% done". You could use it though, something timer.LoadPercentage = (float)doneWork / (float)totalWork; to report progress. Again, most mods don't need this because they start in seconds, but nice to know about.
Note: While it should technically be possible to alter recipes at this point, it's still very tricky to programatically do it. Changes at this point through RecipeManager tend to result in crashes or only paritial lists of recipes returned. Don't let this stop your mod though, just be aware!
IConfigurablePlugin
This interface tells Eco that there should be a config file to load and save to when you load this mod.
using Eco.Core.Plugins;
using Eco.Core.Plugins.Interfaces;
using Eco.Core.Utils;
using Eco.Gameplay.Components.Store;
using Eco.Gameplay.Components.Store.Internal;
using Eco.Gameplay.GameActions;
using Eco.Gameplay.Items;
using Eco.Gameplay.Items.Recipes;
using Eco.Gameplay.Objects;
using Eco.Mods.TechTree;
using Eco.Shared.Localization;
using Eco.Shared.Utils;
using System.Runtime.Versioning;
namespace MyProject
{
[Localized]
public class MyProjectConfiguration : Singleton<MyProjectConfiguration>
{
[LocDescription("Optional. Name of the effect to use.")]
public string EffectName { get; set; } = string.Empty;
[LocDescription("Required, but has default. The amount of minutes that must pass.")]
public int Duration { get; set; } = 30;
}
[SupportedOSPlatform("windows7.0")]
public class MyProjectPlugin: IModKitPlugin, IInitializablePlugin, IShutdownablePlugin, IConfigurablePlugin
{
#region IModKitPlugin
#region IIInitializablePlugin
#region IShutdownablePlugin
#region IConfigurablePlugin
readonly PluginConfig<MyProjectConfiguration> config = new("MyProject");
public ThreadSafeAction<object, string> ParamChanged { get; set; } = new();
public IPluginConfig PluginConfig => this.config;
public object GetEditObject() => this.config.Config;
public void OnEditObjectChanged(object o, string param)
{
if (param == "Duration")
{
Logger.Info($"Someone changed the MyProject's duration: {config.Config.Duration}");
}
// Notify downstream
ParamChanged.Invoke(o, param);
// Write changes to config file
this.SaveConfig();
}
#endregion
}
}
Line 18: [Localized] makes it so that the variable names are translated and localized for the user on the server GUI. Example, duration (English) might become durée (French) if the user's game detects that as their local.
Line 19: The configuration doesn't need to implement Singleton<MyProjectConfiguration> but it's helpful if the mod grows in complexity. What the singleton pattern allows is code from anywhere to get access to the same instance of data. No need to pass it around. If any part of the code wants to see into the config do: MyProjectConfiguration.Obj.Duration, for example. It might not seem super useful, but as code grows in size it becomes very convenient.
Line 21: [LocDescription()] Adds a description to the variable in the server GUI and translates it for the user.
Line 29: Added IConfigurablePlugin to the class.
Line 38: Creates and loads from file the configuration. new("MyProject") is doing a lot of work here. Under the hood Eco is looking for the file in the .../Eco Server/Configs directory. In this case it's looking for MyProject.eco. It also will make the MyProject.eco.template file from default values too.
Line 39: This is the hook for other code to listen for changes to this mods config. Not crazy useful, but make sure it does get called when a changed happens. It's impossible to know if later on some code will use that! Be nice and hook it up. See: line 49 notes.
Line 40: Public access to this mods config as config is private by default.
Line 42-47: Require method for this interface and example showing how to detect what changed. At this point o, config.Config, and MyProjectConfiguration.Obj reference the same data. o is annoying to use since it's passed as object, but the underlying data should be of the form MyProjectConfiguration.
Line 49: This is the code that actually forwards changes to other parts listening to this mod's configuration changes. While that doesn't seem like it would happen offen, it's easy to hookup. The mod is responsible for doing this manually!
Line 51: Saves the config out to file, MyProject.eco. The mod is responsible for saving changes to disk!
IGameActionAware
This is where most mod ideas hook into the engine. This interface allows the mod to be notified when any game action occurs.
With that said, be careful here and code efficiently becuase the ActionPerformed function is called a lot! The first thing the mod should do is filter to exactly the type of action it is waiting for before doing any computation, if possible.
using Eco.Core.Plugins;
using Eco.Core.Plugins.Interfaces;
using Eco.Core.Utils;
using Eco.Gameplay.Aliases;
using Eco.Gameplay.Components.Store;
using Eco.Gameplay.Components.Store.Internal;
using Eco.Gameplay.GameActions;
using Eco.Gameplay.Items;
using Eco.Gameplay.Items.Recipes;
using Eco.Gameplay.Objects;
using Eco.Gameplay.Property;
using Eco.Mods.TechTree;
using Eco.Shared.Localization;
using Eco.Shared.Utils;
using System.Runtime.Versioning;
namespace MyProject
{
// ...
[SupportedOSPlatform("windows7.0")]
public class MyProjectPlugin: IModKitPlugin, IInitializablePlugin, IShutdownablePlugin, IConfigurablePlugin, IGameActionAware
{
#region IModKitPlugin
#region IIInitializablePlugin
#region IShutdownablePlugin
#region IConfigurablePlugin
#region IGameActionAware
public async void ActionPerformed(GameAction action)
{
if (action is ChatSent chat)
{
Logger.Info($"{chat.Citizen} said {chat.Message}");
}
}
public LazyResult ShouldOverrideAuth(IAlias? alias, IOwned? property, GameAction? action)
{
return LazyResult.FailedNoMessage;
}
#endregion
}
}
Line 22: Added IGameActionAware to the class
Line 33: Method that will be called any time there's a game action happening. See a list of game actions here: https://docs.play.eco/api/server/eco.gameplay/Eco.Gameplay.GameActions.html
Line 35: Example of filtering to just ChatSent actions.
Line 41: This is often not used and the method can be left as is. What it does is tell the game if this action should be allowed because of speical ruling. Think: laws that allow a player to hunt on other peoples deeds. That law would be overriding the normal authorization checks. Generally, the mod can return LazyResult.FailedNoMessage to say 'No, don't create a special exception for this action'.
IWorkerPlugin
Here this example just covers a basic worker pattern that runs a task after a delay.
This code will be repeatedly called. Be very efficient with what you do here because it's easy to tank a server's performance by doing expensive computations every loop (4 times a second in this example).
using Eco.Core.Plugins;
using Eco.Core.Plugins.Interfaces;
using Eco.Core.Utils;
using Eco.Core.Utils.Logging;
using Eco.Gameplay.Aliases;
using Eco.Gameplay.Components.Store;
using Eco.Gameplay.Components.Store.Internal;
using Eco.Gameplay.GameActions;
using Eco.Gameplay.Items;
using Eco.Gameplay.Items.Recipes;
using Eco.Gameplay.Objects;
using Eco.Gameplay.Property;
using Eco.Mods.TechTree;
using Eco.Shared.Localization;
using Eco.Shared.Utils;
using Eco.Simulation.Time;
using System.Runtime.Versioning;
namespace MyProject
{
// ...
[SupportedOSPlatform("windows7.0")]
[Worker(Repeatable = true, ThreadPriority = ThreadPriority.Normal)]
public class MyProjectPlugin: IModKitPlugin, IInitializablePlugin, IShutdownablePlugin, IConfigurablePlugin, IGameActionAware, IWorkerPlugin
{
#region IModKitPlugin
#region IIInitializablePlugin
#region IShutdownablePlugin
#region IConfigurablePlugin
#region IGameActionAware
#region IWorkerPlugin
public const int DELAY_MS = 250;
public async Task DoWork(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(DELAY_MS, token);
// Scan the world for something...
// do work on it
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { break; }
catch (Exception e)
{
Logger.Info($"worker crashed: {e}");
}
}
// Stop and cleanup
}
#endregion
}
}
Line 25: This declares the working as something that will run more than once and that it should have a normal threading priority (interrupted only when something with higher priority needs to run).
Line 42: Creates the loop that keeps this repeating. Without it Eco would just call this once and be done! In this the mod is also checking that it hasn't been told to cancel this work. If it has been told to cancel it's wise to save and cleanup before stopping because sooner or later something will end this thread. It might not be nice about it either.
Line 46: This is the line that creates a delay between calls. Very necessary. Without it we would run as many times as the server could handle. Maxing out the CPU! This would be bad and most people might uninstall the mod becuase of performance issues like lag. Adjust the DELAY_MS to give enough time between calls that the server can get to other things, but not so long that this mod is clunky and slow to react. It's a balance.
Line 50: Catching the cancellation and gracefully breaking the while loop.
Line 53: Catching all other exceptions and logging them.
Line 56: If the mod had cleanup to do from this thread, it would do it here.
Logger.cs
To write to the logs I use the class Eco.Core.Utils.Logging.NLogManager.
The Logger.cs file:
using Eco.Core.Utils.Logging;
using Eco.Gameplay.Systems.Messaging.Notifications;
using Eco.Shared.Localization;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.Versioning;
using System.Text;
namespace MyProject
{
static class Logger
{
const string NAME = "MyProject";
[Conditional("DEBUG")]
public static void Debug(string message)
{
NLogManager.GetEcoLogWriter().Write($"[{NAME}] {message}\n");
}
[Conditional("DEBUG")]
[SupportedOSPlatform("windows7.0")]
public static void NewsFeed(string message)
{
NotificationManager.ServerMessageToAll(Localizer.DoStr(message));
}
public static void Info(string message)
{
NLogManager.GetEcoLogWriter().Write($"[{NAME}] {message}\n");
}
}
}
Now in the project it is possible to use this with Logger.Info("Hello, World!"). It will print out [MyProject] Hello, World! to the logs.
Logger.Debug("Debug Message") will do the same thing as Info. In this case printing [MyProject] Debug Message to the logs, but notice the line above it -- [Conditional("DEBUG")]. This line keeps any calls to this method from getting into a release build. They just won't exists and no message will be printed. Use this for printing things that are only helpful during development. Rest assured it's not spamming the logs or the news feed (notice that method has a conditional attribute too).
Additionally, Logger.NewsFeed("Hello") will print Hello in the games feed (the place where you see messages for people logging in and out, other trades, people learning skills, etc.).
[SupportedOSPlatform("windows7.0")] Is only there to prevent the warnings from popping up and is completely optional.
ChatCommands.cs
Having a version number is important because it helps the users know what will work with their Eco version. While it's helpful to include the version number in the name of the DLL, it's also helpful to provide a way for users to check in case that file is renamed/lost. Here's a basic chat command that prints the mod's version out:
using Eco.Gameplay.Systems.Chat;
using Eco.Gameplay.Systems.Messaging.Chat.Commands;
using System;
using System.Collections.Generic;
using System.Runtime.Versioning;
using System.Text;
namespace MyProject
{
[ChatCommandHandler]
[SupportedOSPlatform("windows7.0")]
public static class MyProjectCommands
{
[ChatCommand("myproject", ChatAuthorizationLevel.Moderator)]
public static void myproject(IChatClient chat)
{
chat.MsgLoc($"Version: {MyProjectPlugin.VERSION}");
}
}
}
This adds the command /myproject to Eco. When ran it would reply something like Version: v0.0.1 to the in-game chat.
Note (line 17): MyProjectPlugin.VERSION is not defined in this project. Instead it's defined inside of the plugin's main code, Plugin.cs, because it's mostly changed when the main code changes.
Registration.cs
See page Registered Mods for more information about why register and additional steps necessary.
using Eco.Core.Plugins.Interfaces;
using Eco.Shared.Localization;
using System;
using System.Collections.Generic;
using System.Text;
namespace MyProject
{
public class MyProjectRegistration : IModInit
{
public static ModRegistration Register() => new()
{
ModName = "MyProject",
ModDescription = Localizer.DoStr($"MyProject does something new!"),
ModDisplayName = "My Project"
};
}
}
Change the values of ModName, ModDescription, ModDisplayName to something that fits the mod.
The key in this file is the IModInit interface that's being implemented on line 9. This is what Eco's code will look for when trying to find mods to be registered.
Note: At the time of writing if IModInit was implemented for the same class that IModKitPlugin was the mod would crash. That's why it's being done with a separate class here.
Players Interactions
Game Messages
broadcast, side-message, on-screen, notification
Player Choices
Go through popups types