Extending the Framework¶
For third parties: how to change behaviour without editing the module that owns it. This guide is the concrete walkthrough of the four extension points named in Architecture/02_MODULE_MODEL.md §6.
What you'll have at the end: a working example of each extension point, and a clear sense of which one fits a given change.
0. The honest limitation, first¶
As of ADR-0010, there is a runtime plugin
loader — this supersedes ADR-0002's original "no loader"
decision now that its own named revisit trigger has been met. You do not need to fork this
repository to write a plugin: drop a Plugin subclass and its manifest under Code/Plugins/,
and PluginDiscovery finds and boots it, no editing of any framework module required.
What's still honest about this limitation: this is Stage 1 (in-repo) of a two-stage rollout, per ADR-0010. A plugin's source still has to be compiled into this project once — the "no recompile at all" end state (a compiled Library assembly, no source change) is Stage 2, gated on an editor spike this project hasn't had a chance to run yet. If you need a plugin distributed as a separate, uncompiled artifact today, forking is still the fallback, exactly as before — this project still exists to be forked (01_VISION.md §5). But for the common case — you have this repository, you're adding behaviour without wanting to touch a framework module's own files — write a plugin (§8 below), not a fork.
Two of the four extension points below need no plugin, fork, or recompile at all.
1. Add content — no recompile needed¶
Items, jobs, and recipes are GameResource assets. Add or edit them in your own Assets/
folder and they load like any other — see
02_ADDING_AN_ITEM.md for the full walkthrough. This is the extension
point that needs no code at all, by design
(ADR-0004).
2. Change configuration — no recompile needed¶
Every tuning value that a designer might reasonably want to change is a GameResource
property, not a constant — see
00_GETTING_STARTED.md §6. Placing your own
configuration asset in Assets/ overrides the shipped default with no code change.
3. Observe or veto behaviour — subscribe to an event¶
For "when X happens, also do Y" or "prevent X under some condition", subscribe to the
relevant event from your plugin's Ready (see §8 for the full plugin shape). This touches
nothing in the module you're extending, and — unlike resolving one of its services — needs no
DependsOn declaration at all, per
02_MODULE_MODEL.md §5:
public sealed class DoorPurchaseVettingPlugin : Plugin
{
public override PluginManifest Manifest { get; } = new()
{
Id = "door-purchase-vetting",
DisplayName = "Door Purchase Vetting",
Version = new Version(1, 0, 0),
Author = "you",
RequiresFrameworkVersion = new Version(1, 0, 0),
};
public override void Ready(IServiceResolver services)
{
var events = services.GetRequiredService<IEventBus>();
// Cancellable -- see Architecture/04_EVENT_BUS.md §1. Vetoing here never touches
// World's source; DoorPurchasing is published by PropertyStore.PurchaseAsync before
// ownership is assigned or money moves -- see Code/World/WorldEvents.cs. No DependsOn
// on WorldModule needed to subscribe to its event.
events.Subscribe<DoorPurchasing>(e =>
{
if (e.Buyer.HasPermission("banned-from-property"))
e.Cancel("banned from owning property");
});
}
}
Drop this in Code/Plugins/DoorPurchaseVetting/ (§8) — nothing about it requires editing
EconomyModule or Ownership, and nothing needs registering in ApplejackBootstrap.Modules.
4. Replace a default implementation — Replace a registered service¶
For "the framework's default behaviour is wrong for my server, replace it entirely", declare
the owning module in your plugin's DependsOn and call
IServiceRegistry.Replace<TService>
in your own RegisterServices. Because RegisterServices runs in topological order, the
owning module's RegisterServices — which registered the default with AddSingleton — is
guaranteed to have already run by the time yours does. Code/Plugins/RicherEconomy/ (§8) is a
complete, real, working example of exactly this; the shape in miniature:
public sealed class HouseRulesPlugin : Plugin
{
public override PluginManifest Manifest { get; } = new()
{
Id = "house-rules",
DisplayName = "House Rules",
Version = new Version(1, 0, 0),
Author = "you",
RequiresFrameworkVersion = new Version(1, 0, 0),
};
public override IReadOnlyList<Type> DependsOn { get; } = [typeof(InventoryModule)];
public override void RegisterServices(IServiceRegistry registry, IServiceResolver resolver)
{
// InventoryModule is a declared dependency, so its default IInventoryService is
// already registered -- Replace swaps it for this server's own rules.
registry.Replace<IInventoryService>(new HouseInventoryService(resolver.GetRequiredService<IItemRegistry>()));
}
}
Replace throws if nothing was registered yet (nothing to replace) or if it's called a second
time for the same service — exactly one replacement, same "no last-one-wins" guarantee
AddSingleton gives first-party registration
(Architecture/03_SERVICE_REGISTRY.md §2).
Two plugins that both try to replace the same service is a real, expected collision with N
plugins installed — the exception names both claimants, per that same section.
5. Add UI¶
Register a HUD element from your plugin's Ready, via IHudRegistry (declare UiModule in
DependsOn to resolve it) — see
Architecture/11_UI_ARCHITECTURE.md §3.
A UI extension never needs access to a module's internals, because UI is held to that
boundary just as strictly as gameplay code is
(Architecture/11_UI_ARCHITECTURE.md §Ownership).
Your panel must be a plain Panel (RegisterHudElement<TPanel> requires TPanel : Panel,
new()), not a PanelComponent — see Code/Plugins/RicherEconomy/RicherEconomyHudPanel.razor
for a real, correctly-shaped example, and that same file's header comment for a separate,
pre-existing gap it surfaced in Code/Ui/HudRoot.razor that currently keeps any registered
panel — plugin or first-party — from actually rendering. Your RegisterHudElement call is
correct either way; that gap is tracked separately and is not something your plugin needs to
work around.
6. Choosing the right extension point¶
| You want to... | Use |
|---|---|
| Add a sandwich, a job, a recipe | An asset (02_ADDING_AN_ITEM.md) |
| Change a price, cooldown, or capacity | A configuration override |
| React to something happening, without changing the rule | Subscribe to an event |
| Veto or modify an outcome | Subscribe to a CancellableEvent |
| Replace how a whole rule works | Register a different service implementation |
| Add a new kind of gameplay entirely | A plugin (§8) — or, if it's first-party-scoped |
behaviour with no reason to isolate its failures, a module in ApplejackBootstrap.Modules |
7. Verify it worked¶
Whichever extension point you used: the framework's own unit tests still pass unmodified
(you should never need to edit a framework test to make your extension work — if you do,
you've reached into an implementation detail, not a real extension point), and your addition
has its own test or manual verification following the same standards as the rest of this
project — see Standards/03_TESTING_STANDARDS.md. For a
plugin specifically: once loaded, it shows up in the dev menu's Plugins section (IPluginCatalog,
Architecture/23_PLUGIN_SYSTEM.md §4) as
Loaded — if it's Disabled or Failed instead, the listed reason names exactly what went
wrong, and the same is logged at server start.
8. Writing a plugin¶
A plugin is one class deriving from Plugin (Applejack.Core.Plugins), plus its manifest,
under its own folder in Code/Plugins/<YourPluginName>/ — see
Architecture/23_PLUGIN_SYSTEM.md §1
for the full contract and
Architecture/01_PROJECT_LAYOUT.md §2 for where it
sits relative to Code/Core/Plugins/ (the loader itself — not where your plugin goes).
Code/Plugins/RicherEconomy/ is a complete, real, working reference: read its README.md
first, then its four files in the order that README names.
The shape, minimally:
public sealed class MyPlugin : Plugin
{
public override PluginManifest Manifest { get; } = new()
{
Id = "my-plugin", // unique across your server's plugins
DisplayName = "My Plugin",
Version = new Version(1, 0, 0),
Author = "you",
RequiresFrameworkVersion = new Version(1, 0, 0),
};
// Optional: declare a first-party module to resolve its services (§4), or another
// plugin's id via Manifest.DependsOn to depend on it. Neither is required for the
// simplest plugin.
public override IReadOnlyList<Type> DependsOn { get; } = [];
// Configure/RegisterServices/Initialize/Ready/Shutdown -- the exact same five-stage
// lifecycle a first-party module has, per Architecture/02_MODULE_MODEL.md §1-§2. Override
// only the stages you need; every one has a safe empty default.
}
What you get for free, and what you don't. Your Manifest.Id and Manifest.DisplayName
become Id/Name automatically (both are sealed override on Plugin — you set them once,
on the manifest, not twice). A malformed manifest, a missing or cyclic plugin dependency, or a
lifecycle stage that throws all disable your plugin without stopping the server — see
Architecture/23_PLUGIN_SYSTEM.md §4-§5
for exactly what each failure looks like from a server owner's side. What you don't get: a
first-party module can never depend on your plugin (the direction only runs one way,
Architecture/23_PLUGIN_SYSTEM.md §3),
and there is no sandboxing — your plugin runs with full framework access, and a server owner
installing it is trusting your code completely
(ADR-0010).
No registration step. Unlike a module, you never add your plugin to
ApplejackBootstrap.Modules — PluginDiscovery finds every concrete Plugin subclass
automatically the moment its source is part of the compiled project (Stage 1; see §0 above for
what Stage 2 changes about that last clause).