Inter-plugin services
Paper’s equivalent is the ServicesManager (Vault-style). The idea: a plugin registers an interface implementation as a service; other plugins look it up by interface without knowing which plugin provides it.
Every plugin has Services, a ServiceManager:
| Member | What it does |
|---|---|
Register<T>(instance, priority) |
offers an implementation; the registration belongs to your plugin and is removed when it unloads |
Unregister<T>() |
removes your registration of T |
TryGet<T>() |
the best provider, or null when there is none |
Get<T>() |
the best provider; throws ServiceUnavailableException when there is none |
GetAll<T>() |
every registration, best first, as ServiceRegistration<T> (Instance, Provider, Priority) |
Changed<T>(Action) |
runs when the set of providers of T changes (a registration, an unregistration, or a provider unloading) |
ServicePriority is Lowest, Low, Normal, High or Highest. The highest priority wins; among equal priorities the first registration wins. A plugin replaces another provider by registering at a higher priority.
1. The contract
Section titled “1. The contract”The API already ships some service interfaces in Mc.Scripting.Services, so a consumer needs nothing but the API to see them:
| Interface | What it is | Built-in provider |
|---|---|---|
IEconomy |
balances and transfers, in minor units (long), with a Currency |
none: without a provider, money rules do nothing |
IBanList |
the server’s ban lists | built in (Lowest) |
IPlaceholders |
%token% values for text |
built in (Normal), one shared registry |
IPlayerMeta |
per-player values and affixes (text such as a prefix) | built in (Lowest), no meta |
This page uses IEconomy. The provider below implements it in memory; the shop buys from whichever economy is registered.
For an interface of your own, put it in a type both plugins can see: a plugin that lists another in depends gets that plugin’s assembly, so both sides share the same type. Keep such interfaces small and stable: adding a member breaks every provider and consumer compiled against the old shape. Add a new interface (IWarpDirectoryV2) instead.
2. The provider
Section titled “2. The provider”{ "id": "bank", "name": "Bank", "version": "1.0.0", "api": "1.0", "main": "Bank.PluginMain", "description": "An in-memory economy"}using Mc.Scripting.Services;
namespace Bank;
public sealed class PluginMain : Plugin{ protected override void OnEnable() { Services.Register<IEconomy>(new BankEconomy(), ServicePriority.Normal); }}
/// <summary>An in-memory economy with one currency of two fraction digits (1050 is 10.50). Nothing is saved to disk.</summary>internal sealed class BankEconomy : IEconomy{ private readonly Dictionary<Uuid, long> _balances = new();
public Currency Currency { get; } = new("dollar", "$", 2, "dollars");
public event Action<Uuid?>? Changed;
public bool HasAccount(Uuid player) => _balances.ContainsKey(player);
public long Balance(Uuid player) => _balances.GetValueOrDefault(player);
public bool Has(Uuid player, long minor) => Balance(player) >= minor;
public EconomyResult Deposit(Uuid player, long minor, string reason) { if (minor <= 0) return EconomyResult.Refused; _balances[player] = Balance(player) + minor; Changed?.Invoke(player); return EconomyResult.Ok; }
public EconomyResult Withdraw(Uuid player, long minor, string reason) { if (minor <= 0) return EconomyResult.Refused; if (!Has(player, minor)) return EconomyResult.NotEnough; _balances[player] -= minor; Changed?.Invoke(player); return EconomyResult.Ok; }
public EconomyResult Transfer(Uuid from, Uuid to, long minor, string reason) { if (minor <= 0) return EconomyResult.Refused; if (!Has(from, minor)) return EconomyResult.NotEnough; _balances[from] -= minor; _balances[to] = Balance(to) + minor; Changed?.Invoke(null); // two accounts changed: null means "many" return EconomyResult.Ok; }}Currency and EconomyResult are in Mc.Scripting.Services next to IEconomy. Amounts are always long minor units, so no floating point is involved.
3. The consumer
Section titled “3. The consumer”{ "id": "shop", "name": "Shop", "version": "1.0.0", "api": "1.0", "main": "Shop.PluginMain", "description": "Sell things for money", "softDepends": ["bank >=1.0"]}softDepends loads bank first when it is installed, and the shop still loads without it. Use depends only when the consumer cannot work at all without the provider’s types. The built-in IEconomy needs neither, because the API is always there.
using Mc.Scripting.Services;
namespace Shop;
public sealed class PluginMain : Plugin{ private IEconomy? _economy;
protected override void OnEnable() { _economy = Services.TryGet<IEconomy>(); // the best provider, null when none is registered
// re-resolve whenever the set of providers changes (also when a provider unloads) Services.Changed<IEconomy>(() => _economy = Services.TryGet<IEconomy>());
Commands.Register(Command.Literal("buy") .Then(Command.Argument("item", Arg.Word()) .Executes(ctx => Buy(ctx.RequirePlayer(), ctx.GetString("item"))))); }
private int Buy(Player player, string item) { if (_economy is null) throw new CommandFailure("The shop is closed (no economy plugin)."); const long price = 1000; // minor units: 10.00 with two fraction digits if (_economy.Withdraw(player.Uuid, price, $"buy {item}") != EconomyResult.Ok) throw new CommandFailure($"Not enough money ({_economy.Currency.Format(price)} needed)."); player.SendMessage($"Bought {item} for {_economy.Currency.Format(price)}."); return 1; }}The shop keeps its reference in a field and refreshes it in the Changed callback, so it never holds a provider that has gone away.
Hot reload and unloading
Section titled “Hot reload and unloading”- Registrations come and go with their plugin. Unloading a provider, or registering a replacement at a higher priority, fires
Changedin every plugin that listens. An object a consumer still holds from before the change belongs to the old provider, so drop it: theChangedcallback above is the signal. Never cache a service in a static field. GetversusTryGet: useGet<T>()when the provider is required and a missing one is a bug (it throwsServiceUnavailableException). UseTryGet<T>()when no provider is a normal state, as in the shop.- Unloading or reloading a provider also unloads or reloads the plugins that list it in
dependsorsoftDepends, because they may hold its types. They are unloaded before the provider and loaded again after it.
When not to use services
Section titled “When not to use services”For fire-and-forget notifications between plugins (“a player bought something”), define a custom event and let others subscribe. Services are for request/response APIs where the caller needs an answer.