Skip to content

Implementing a Plugin System

Mahtra edited this page Aug 2, 2026 · 1 revision

Implementing a Plugin System (for a new host script)

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.lic and hunting-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.

1. What this pattern is

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:

  1. One-directional coupling. Plugins depend on the host's hook names; the host knows nothing about any specific plugin.
  2. 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?.
  3. Fail-safe. A broken or missing plugin degrades to the host's built-in behavior; it never breaks the host or the other plugins.
  4. Convention over configuration. File naming, load order, and the global handle follow a fixed convention so every host looks the same to plugin authors.

2. When to add it

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).

3. The invariant pattern (shared by both implementations)

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 via attr_reader, populated by a class-level register_plugin(plugin).
  • Two dispatch primitives:
    • fire_hook(name, *args, **kwargs) - a decision dispatch. Polls plugins in registration order, returns the first non-nil result, nil if 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 } over scripts/custom/<script>-plugin-*.rb, run before the orchestrator instance is constructed (so a plugin's after_initialize can fire).
  • A singleton global handle ($COMBAT_TRAINER, $HUNTING_BUDDY) assigned right after construction.

4. The one decision that varies: dispatch receiver

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 HuntingBuddy object 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 *Process classes (AbilityProcess, SpellProcess, ...) constructed without an orchestrator backreference; they fire hooks via CombatTrainer.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 $HOST per 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.

5. Precise contracts

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-nil value and stop polling.
  • nil means "this plugin defers" - continue to the next plugin; if all defer, fire_hook returns nil and the caller uses its built-in default.
  • false is a real answer, not a defer. It stops the poll and is returned. (Callers must therefore distinguish nil from false: use result.nil?, never unless result.)
  • Convention for loop hooks: a return of :break signals 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, load each.
  • Rescue ScriptError as well as StandardError (see the gotcha in section 10) so a malformed plugin file cannot abort host startup.
  • Run before the orchestrator is constructed.

6. Canonical reference implementation

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.main

Instance-level variant (if only the orchestrator fires hooks)

Keep 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.

7. Naming and file conventions (do not deviate)

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

8. Designing hooks

  • 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_hook decision hook (and the built-in code path must honor a non-nil return and fall back on nil). If plugins only react, it is a notify_hook.
  • Always provide after_initialize and cleanup. 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.

9. Testing

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'
end

Use 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.

10. Gotchas (non-obvious, learned from the real implementations)

  • ScriptError is not a StandardError. load raises SyntaxError (a ScriptError) for a malformed plugin and LoadError for a missing require. A loader that rescues only StandardError will let a single typo'd plugin file abort host startup. combat-trainer's loader rescues ScriptError, StandardError; hunting-buddy's currently rescues only StandardError (a latent gap). Rescue both.
  • nil vs false. Decision-hook callers must branch on result.nil?. Writing return x unless result treats a legitimate false as "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_initialize never fires (it is emitted at the end of initialize).
  • Class-level registry is shared process-wide. Fine for singleton hosts; reset it in specs.
  • Keyword-only hooks and **kwargs forwarding. Dispatch forwards *args, **kwargs; define hooks with the exact keyword names you document, or Ruby raises ArgumentError (which the isolation will swallow, silently dropping the hook - hard to debug without the debug flag on).

11. Porting checklist

  • @registered_plugins = [] + attr_reader + class-level register_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_initialize at end of initialize; cleanup in before_dying.
  • Main-loop hooks as needed (before_main, *_tick honoring :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.

12. Reference sources

Clone this wiki locally