-
Notifications
You must be signed in to change notification settings - Fork 191
Implementing a Plugin System
Audience note. This page is a technical implementation spec, written to be consumed by developers and by LLM systems that are adding a plugin system to a new host script. It is deliberately dense and code-first. It is not the guide for writing a plugin against an existing host - for that, see Script Plugin System and the per-script pages (Combat-Trainer Plugins, Hunting-Buddy Plugins).
Two working implementations exist in
dr-scripts:combat-trainer.licandhunting-buddy.lic. This page distills the shared, invariant pattern from both, states the one design decision that differs between them and why, and gives a canonical reference implementation plus a porting checklist. If you only read the two source files you will get the what but not the why - and the why (class-level vs instance-level dispatch) is the thing that is easy to get wrong.
A plugin system lets external, user-supplied Ruby files observe and influence a host script's behavior at defined points ("hooks"), without editing (forking) the host. The design goals, in priority order:
- One-directional coupling. Plugins depend on the host's hook names; the host knows nothing about any specific plugin.
-
Duck-typed, zero-ceremony plugins. A plugin is any object that implements one or more hook methods. No base class, no registration of which hooks it implements - the host discovers that at call time via
respond_to?. - Fail-safe. A broken or missing plugin degrades to the host's built-in behavior; it never breaks the host or the other plugins.
- Convention over configuration. File naming, load order, and the global handle follow a fixed convention so every host looks the same to plugin authors.
Add a plugin system when users legitimately need to customize a script's decisions or react to its lifecycle, and you want those customizations to survive upstream updates. If the only extension point you need is a single boolean toggle, a setting is simpler. The plugin system earns its keep when there are multiple, open-ended extension points (a lifecycle plus one or more decision seams).
Every host that implements this pattern has all of the following. These parts do not vary between combat-trainer and hunting-buddy:
- A class-level registry:
@registered_plugins = []on the host class, exposed viaattr_reader, populated by a class-levelregister_plugin(plugin). -
Two dispatch primitives:
-
fire_hook(name, *args, **kwargs)- a decision dispatch. Polls plugins in registration order, returns the first non-nilresult,nilif none answered. -
notify_hook(name, *args, **kwargs)- a notification dispatch. Calls every plugin that implements the hook, ignores return values.
-
-
respond_to?gating: a plugin that does not implement a given hook is skipped, never errored. -
Per-plugin error isolation: each plugin call is wrapped in
begin/rescue; an exception is swallowed (echoed only when the host's debug flag is on) so one bad plugin cannot abort dispatch. -
method_missing+respond_to_missing?forwarding: unknown calls on the host instance are forwarded to the first plugin that responds, so external scripts can drive plugins through the global handle ($HOST.custom_method(...)). -
A boot-time loader:
Dir.glob(...).sort.each { load }overscripts/custom/<script>-plugin-*.rb, run before the orchestrator instance is constructed (so a plugin'safter_initializecan fire). -
A singleton global handle (
$COMBAT_TRAINER,$HUNTING_BUDDY) assigned right after construction.
This is the single most important choice, and the two implementations make it differently. The code does not explain why - this section is the part you cannot recover from reading the sources.
| Dispatch methods are... | Called as... | |
|---|---|---|
| hunting-buddy |
private instance methods (using self.class.registered_plugins) |
fire_hook(:find_room, ...) from within HuntingBuddy instance methods |
| combat-trainer | class methods |
CombatTrainer.fire_hook(:warhorn_cooldown_active?, ...) from anywhere |
The rule:
- If only the orchestrator instance ever fires hooks, instance-level dispatch is enough (hunting-buddy: one
HuntingBuddyobject does all the work). - If decoupled collaborators need to fire hooks - objects that hold no reference to the orchestrator instance - the dispatch methods must be class-level. combat-trainer splits its work across independent
*Processclasses (AbilityProcess,SpellProcess, ...) constructed without an orchestrator backreference; they fire hooks viaCombatTrainer.fire_hook(...), which is only possible because dispatch is class-level.
Recommendation for a new host: default to class-level dispatch. It is the safe superset - it works whether or not you later add collaborator-fired hooks, and the registry is class-level in both designs anyway. Only choose private instance dispatch if you are confident the single orchestrator will remain the sole hook firer (it is marginally more encapsulated). The reference implementation below is class-level.
Note: class-level state assumes a singleton host (one
$HOSTper Lich session), which is true for these scripts. Do not use this pattern as-is if you intend to run multiple concurrent orchestrator instances in one process.
Implement these semantics exactly; plugin authors and the author-facing docs rely on them.
Decision hooks (fire_hook)
- Poll in registration order (= sorted plugin-file load order).
- Return the first non-
nilvalue and stop polling. -
nilmeans "this plugin defers" - continue to the next plugin; if all defer,fire_hookreturnsniland the caller uses its built-in default. -
falseis a real answer, not a defer. It stops the poll and is returned. (Callers must therefore distinguishnilfromfalse: useresult.nil?, neverunless result.) - Convention for loop hooks: a return of
:breaksignals the host to stop its main loop.
Notification hooks (notify_hook)
- Call every implementing plugin, in registration order.
- Ignore all return values; return
nil.
Both
- Skip any plugin not responding to the hook (
respond_to?). - Isolate exceptions per plugin:
rescue, echo under the debug flag, continue.
Loader
- Glob
scripts/custom/<script>-plugin-*.rb, sorted,loadeach. - Rescue
ScriptErroras well asStandardError(see the gotcha in section 10) so a malformed plugin file cannot abort host startup. - Run before the orchestrator is constructed.
Copy, rename MyScript/my-script/$MY_SCRIPT/$debug_mode_myscript, and fill in the hook-firing call sites.
class MyScript
# --- Plugin registry + dispatch (class-level) ---
@registered_plugins = []
class << self
attr_reader :registered_plugins
# Called from plugin files at load time, before the instance exists.
def register_plugin(plugin)
@registered_plugins << plugin
end
# Decision dispatch: first non-nil result wins; nil means "no plugin decided".
def fire_hook(hook_name, *args, **kwargs)
registered_plugins.each do |plugin|
next unless plugin.respond_to?(hook_name)
begin
result = plugin.send(hook_name, *args, **kwargs)
return result unless result.nil? # NB: false is a real answer
rescue StandardError => e
echo "Plugin #{plugin.class.name} error in #{hook_name}: #{e.message}" if $debug_mode_myscript
end
end
nil
end
# Notification dispatch: every implementing plugin runs; returns are ignored.
def notify_hook(hook_name, *args, **kwargs)
registered_plugins.each do |plugin|
next unless plugin.respond_to?(hook_name)
begin
plugin.send(hook_name, *args, **kwargs)
rescue StandardError => e
echo "Plugin #{plugin.class.name} error in #{hook_name}: #{e.message}" if $debug_mode_myscript
end
end
nil
end
end
# --- Forward unknown instance calls to plugins ($MY_SCRIPT.custom_method) ---
def method_missing(method_name, *args, **kwargs, &block)
self.class.registered_plugins.each do |plugin|
return plugin.send(method_name, *args, **kwargs, &block) if plugin.respond_to?(method_name)
end
super
end
def respond_to_missing?(method_name, include_private = false)
self.class.registered_plugins.any? { |plugin| plugin.respond_to?(method_name) } || super
end
def initialize
# ... build state ...
self.class.notify_hook(:after_initialize, self)
end
def main
self.class.notify_hook(:before_main, self, @state)
counter = 0
loop do
# ... do one unit of work ...
counter += 1
break if self.class.fire_hook(:main_tick, self, @state, counter: counter) == :break
# ... loop-exit conditions ...
end
self.class.notify_hook(:after_main, self, @state)
end
end
# --- Loader: run BEFORE constructing the orchestrator ---
Dir.glob(File.join(SCRIPT_DIR, 'custom', 'my-script-plugin-*.rb')).sort.each do |plugin_file|
load plugin_file
rescue ScriptError, StandardError => e
# ScriptError (SyntaxError/LoadError) does NOT descend from StandardError;
# without catching it, one malformed plugin file aborts host startup.
echo "Failed to load my-script plugin #{File.basename(plugin_file)}: #{e.message}"
end
# --- Teardown hook belongs in the script's before_dying block ---
before_dying do
MyScript.notify_hook(:cleanup, $MY_SCRIPT) if $MY_SCRIPT
end
$MY_SCRIPT = MyScript.new
$MY_SCRIPT.mainKeep register_plugin class-level (plugin files call it before the instance exists), but move fire_hook/notify_hook to private instance methods and have them read self.class.registered_plugins. Call them bare (fire_hook(:find_room, self, ...)) from inside orchestrator methods. This is the hunting-buddy shape. Do this only when you are sure no non-orchestrator collaborator will ever need to dispatch.
These make every host uniform for plugin authors; matching them is the whole point of convergence.
| Thing | Convention | Example |
|---|---|---|
| Plugin file glob | scripts/custom/<script>-plugin-*.rb |
my-script-plugin-foo.rb |
| Registration call | <HostClass>.register_plugin(instance) |
MyScript.register_plugin(Foo.new) |
| Global handle |
$SCREAMING_SNAKE_CASE, singleton |
$MY_SCRIPT |
| Debug flag | $debug_mode_<short> |
$debug_mode_myscript |
-
Pass context, not globals. Every hook's first argument is the orchestrator (
self); pass the relevant live state object(s) next. Prefer keyword arguments for anything a plugin might want by name (counter:,room_id:,type:) - it keeps call sites self-documenting and lets you add kwargs later without breaking plugins. -
Choose the kind deliberately. If a plugin should be able to replace a decision, it is a
fire_hookdecision hook (and the built-in code path must honor a non-nilreturn and fall back onnil). If plugins only react, it is anotify_hook. -
Always provide
after_initializeandcleanup. They are the universal lifecycle anchors; plugin authors expect them on every host. -
Add a per-iteration decision hook (
*_tick) to any long-running loop, honoring:break. -
Name feature seams after the feature, not after a plugin (e.g.
warhorn_cooldown_active?), and document each one where it is fired. -
Keep the built-in behavior intact behind the seam. A decision hook returning
nil(or no plugin present) must reproduce today's behavior byte-for-byte.
Mirror the host's real classes in specs (dr-scripts loads them via a load_lic_class helper). Cover, at minimum:
RSpec.describe MyScript do
before(:each) { MyScript.registered_plugins.clear }
after(:each) { MyScript.registered_plugins.clear }
it 'accumulates plugins in registration order' # register_plugin
it 'fire_hook returns nil when none registered/implement'
it 'fire_hook returns the first non-nil and stops polling'
it 'fire_hook treats false as an answer (no fall-through)' # the classic bug
it 'fire_hook isolates a raising plugin and uses the next'
it 'notify_hook calls every implementing plugin'
it 'notify_hook continues after one plugin raises'
it 'method_missing forwards to the first responding plugin'
it 'respond_to? is true when a plugin implements the method'
endUse small stub plugins: a RecordingPlugin (captures calls, returns a preset value), an ExplodingPlugin (raises in every hook), and an InertPlugin (implements nothing). Reset the class-level registry between examples, and reset any shared UserVars a seam touches so decision tests are order-independent.
-
ScriptErroris not aStandardError.loadraisesSyntaxError(aScriptError) for a malformed plugin andLoadErrorfor a missingrequire. A loader that rescues onlyStandardErrorwill let a single typo'd plugin file abort host startup. combat-trainer's loader rescuesScriptError, StandardError; hunting-buddy's currently rescues onlyStandardError(a latent gap). Rescue both. -
nilvsfalse. Decision-hook callers must branch onresult.nil?. Writingreturn x unless resulttreats a legitimatefalseas "defer" and is a real bug. - Load order = registration order = dispatch order. It is the sorted filename order of the plugin files. If ordering matters to users, tell them to prefix filenames.
-
Loader must run before construction. Otherwise a plugin's
after_initializenever fires (it is emitted at the end ofinitialize). - Class-level registry is shared process-wide. Fine for singleton hosts; reset it in specs.
-
Keyword-only hooks and
**kwargsforwarding. Dispatch forwards*args, **kwargs; define hooks with the exact keyword names you document, or Ruby raisesArgumentError(which the isolation will swallow, silently dropping the hook - hard to debug without the debug flag on).
-
@registered_plugins = []+attr_reader+ class-levelregister_plugin. -
fire_hook(first non-nil,respond_to?gate, per-plugin rescue, debug echo). -
notify_hook(all plugins, ignore returns, same gating/rescue). - Decide dispatch receiver (class-level default; instance-level only if sole-orchestrator). See section 4.
-
method_missing+respond_to_missing?forwarding. -
after_initializeat end ofinitialize;cleanupinbefore_dying. - Main-loop hooks as needed (
before_main,*_tickhonoring:break,after_main). - Feature seams for real decision points (decision hook + intact built-in fallback).
- Loader over
scripts/custom/<script>-plugin-*.rb, sorted,rescue ScriptError, StandardError, before construction. - Global handle
$SCREAMING_SNAKE; debug flag$debug_mode_<x>. - Specs per section 9; rubocop clean.
- An author-facing wiki page listing the hook catalog (see the two existing per-script pages), and a
# See: <wiki URL>cross-link in the in-code doc block.
-
combat-trainer.lic- class-level dispatch; collaborator-fired seams;ScriptError-robust loader. Author docs: Combat-Trainer Plugins. -
hunting-buddy.lic- private instance-level dispatch; single orchestrator. Author docs: Hunting-Buddy Plugins. - Shared model for plugin authors: Script Plugin System.