Skip to content

Commit af380d9

Browse files
MahtraDRclaude
andauthored
Add plugin system to hunting-buddy (elanthia-online#7354)
## Summary - Adds a generic plugin architecture to hunting-buddy that lets custom scripts hook into the hunting lifecycle without modifying the core script - Plugins auto-load from `scripts/custom/hunting-buddy-plugin-*.rb` and register via `HuntingBuddy.register_plugin(instance)` - Fixes combat-trainer stop to use a 30s timeout with force-kill instead of an infinite `pause 1 while` loop ## Plugin hooks | Hook | Type | Purpose | |------|------|---------| | `:after_initialize` | notify | Post-setup customization | | `:find_room` | fire (first non-nil wins) | Plugin-first room finding | | `:before_hunt` / `:after_hunt` | notify | Pre/post combat setup and teardown | | `:hunt_tick` | fire (return `:break` to end) | Per-second loop injection | | `:hunt_status` | notify | Periodic status reporting | | `:cleanup` | notify | `before_dying` teardown | ## Design - `fire_hook` returns the first non-nil result from any plugin (for decision hooks like `:find_room`) - `notify_hook` calls all plugins and swallows errors (for event hooks) - `method_missing` forwards unknown calls to plugins, enabling external API like `$HUNTING_BUDDY.custom_method` - All hooks are no-ops when no plugins are registered -- zero behavior change for existing users ## Test plan - [x] Run hunting-buddy with no plugins in `scripts/custom/` -- behavior identical to current - [x] Run hunting-buddy with a test plugin that implements `after_initialize` -- verify hook fires - [x] Run hunting-buddy with a plugin that raises in a hook -- verify error is logged and script continues - [x] Verify combat-trainer force-kill after 30s timeout works when CT hangs - [x] Verify `$HUNTING_BUDDY.stop_hunting` and `$HUNTING_BUDDY.next_hunt` still work as before 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 10e4a8b commit af380d9

2 files changed

Lines changed: 875 additions & 1 deletion

File tree

hunting-buddy.lic

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,57 @@
33
=end
44

55
class HuntingBuddy
6+
# Plugin system -- plugins register to receive lifecycle hooks.
7+
# Plugins are loaded from scripts/custom/hunting-buddy-plugin-*.rb
8+
# and register via HuntingBuddy.register_plugin(instance).
9+
# Plugins implement hook methods (after_initialize, find_room, etc.)
10+
# which are called at key points in the hunting lifecycle.
11+
@registered_plugins = []
12+
13+
class << self
14+
attr_reader :registered_plugins
15+
16+
def register_plugin(plugin)
17+
@registered_plugins << plugin
18+
end
19+
end
20+
21+
attr_reader :settings, :hunting_data, :hunting_zones, :hunting_info
22+
attr_accessor :my_room_claim, :need_relocation, :stopped_for_bleeding
23+
24+
private
25+
26+
# Fire a hook on all registered plugins.
27+
# Returns the first non-nil result (for decision hooks like :find_room).
28+
# Plugins that don't implement a hook are silently skipped.
29+
def fire_hook(hook_name, *args, **kwargs)
30+
self.class.registered_plugins.each do |plugin|
31+
next unless plugin.respond_to?(hook_name)
32+
begin
33+
result = plugin.send(hook_name, *args, **kwargs)
34+
return result unless result.nil?
35+
rescue => e
36+
echo "Plugin #{plugin.class.name} error in #{hook_name}: #{e.message}" if $debug_mode_hunting
37+
end
38+
end
39+
nil
40+
end
41+
42+
# Fire a hook but don't use return values (for notification hooks).
43+
# All plugins get called; errors in one don't break others.
44+
def notify_hook(hook_name, *args, **kwargs)
45+
self.class.registered_plugins.each do |plugin|
46+
next unless plugin.respond_to?(hook_name)
47+
begin
48+
plugin.send(hook_name, *args, **kwargs)
49+
rescue => e
50+
echo "Plugin #{plugin.class.name} error in #{hook_name}: #{e.message}" if $debug_mode_hunting
51+
end
52+
end
53+
end
54+
55+
public
56+
657
def initialize
758
arg_definitions = [[]]
859
args = parse_args(arg_definitions, true)
@@ -67,6 +118,8 @@ class HuntingBuddy
67118
end
68119
@hunting_info += infos
69120
end
121+
122+
notify_hook(:after_initialize, self)
70123
end
71124

72125
def main
@@ -280,6 +333,16 @@ class HuntingBuddy
280333
UserVars.friends = @settings.hunting_buddies || []
281334
UserVars.hunting_nemesis = @settings.hunting_nemesis || []
282335

336+
# Let plugins handle room finding first
337+
plugin_result = fire_hook(:find_room, self, zones_to_search, waiting_room)
338+
return plugin_result unless plugin_result.nil?
339+
340+
# NOTE: The `return` statements inside this block exit find_hunting_room?
341+
# on the first iteration, so `any?` never checks beyond the first zone.
342+
# This is a pre-existing bug -- only the first zone in the list is ever
343+
# searched. Fixing it requires removing the inner `return` keywords and
344+
# letting `any?` evaluate the block's truthy/falsy result per iteration.
345+
# rubocop:disable Lint/UnreachableLoop
283346
return zones_to_search.any? do |zone_to_search|
284347
escort_info = @escort_zones[zone_to_search]
285348
if escort_info
@@ -378,6 +441,7 @@ class HuntingBuddy
378441
prefer_buddies)
379442
end
380443
end
444+
# rubocop:enable Lint/UnreachableLoop
381445
end
382446

383447
# ------------------------------------------------------------
@@ -484,6 +548,11 @@ class HuntingBuddy
484548
def hunt(args, duration, stop_on_high_skills, stop_on_low_skills, stop_on_boxes, stop_on_no_moons, stop_on_encumbrance, stop_on_burgle_cooldown)
485549
$COMBAT_TRAINER = nil
486550
hunting_room = Room.current.id
551+
552+
notify_hook(:before_hunt, self, hunting_room: hunting_room)
553+
hunting_room = Room.current.id # Update in case a plugin relocated us
554+
return if @stop_hunting
555+
487556
Flags.add('hunting-buddy-familiar-drag', /^Your .+ grabs ahold of you and drags you .+, out of combat.+$/)
488557
Flags.add('hunting-buddy-stop-to-burgle', /^A tingling on the back of your neck draws attention to itself by disappearing, making you believe the heat is off from your last break in/)
489558
DRC.message("***STATUS*** Beginning hunt '#{args}' for '#{duration}' minutes")
@@ -500,6 +569,11 @@ class HuntingBuddy
500569
counter = 0
501570
loop do
502571
clear
572+
573+
# Let plugins inject checks into the hunt loop
574+
plugin_break = fire_hook(:hunt_tick, self, counter: counter)
575+
break if plugin_break == :break
576+
503577
if DRStats.health < @settings.health_threshold
504578
DRC.message("***STATUS*** Exiting because low health: #{DRStats.health} < #{@settings.health_threshold}")
505579
fput('avoid all')
@@ -553,6 +627,7 @@ class HuntingBuddy
553627
Flags.reset('hunting-buddy-familiar-drag')
554628
end
555629
if (counter % 60).zero?
630+
notify_hook(:hunt_status, self, counter: counter, duration: duration)
556631
# To avoid spamming encumbrance checks, this logic is inside
557632
# the one minute status update section so it's done only periodically.
558633
if encumbered?(stop_on_encumbrance)
@@ -576,8 +651,19 @@ class HuntingBuddy
576651
counter += 1
577652
pause 1
578653
end
654+
655+
notify_hook(:after_hunt, self, hunting_room: hunting_room, counter: counter)
656+
579657
$COMBAT_TRAINER.stop
580-
pause 1 while $COMBAT_TRAINER.running || Script.running?('combat-trainer')
658+
ct_stop_timer = Time.now
659+
while Script.running?('combat-trainer') && $COMBAT_TRAINER.running
660+
if Time.now - ct_stop_timer > 30
661+
DRC.message("***WARNING*** combat-trainer did not stop gracefully after 30s, force-killing")
662+
stop_script('combat-trainer') if Script.running?('combat-trainer')
663+
break
664+
end
665+
pause 1
666+
end
581667
DRC.retreat
582668
end
583669

@@ -588,6 +674,22 @@ class HuntingBuddy
588674
def next_hunt
589675
@next_hunt = true
590676
end
677+
678+
# Forward method calls to plugins that implement them.
679+
# Allows external scripts to call plugin methods via $HUNTING_BUDDY.
680+
# Example: $HUNTING_BUDDY.force_relocation!(room_id)
681+
def method_missing(method_name, *args, **kwargs, &block)
682+
self.class.registered_plugins.each do |plugin|
683+
if plugin.respond_to?(method_name)
684+
return plugin.send(method_name, *args, **kwargs, &block)
685+
end
686+
end
687+
super
688+
end
689+
690+
def respond_to_missing?(method_name, include_private = false)
691+
self.class.registered_plugins.any? { |p| p.respond_to?(method_name) } || super
692+
end
591693
end
592694

593695
before_dying do
@@ -598,6 +700,17 @@ before_dying do
598700
Flags.flags.keys
599701
.select { |flag_name| flag_name.start_with?('hunting-buddy-') }
600702
.each { |flag_name| Flags.delete(flag_name) }
703+
# Let plugins clean up
704+
$HUNTING_BUDDY&.send(:notify_hook, :cleanup, $HUNTING_BUDDY) rescue nil
705+
end
706+
707+
# Load plugins from custom directory
708+
Dir.glob(File.join(SCRIPT_DIR, 'custom', 'hunting-buddy-plugin-*.rb')).sort.each do |plugin_file|
709+
begin
710+
load plugin_file
711+
rescue => e
712+
echo "Failed to load hunting-buddy plugin #{File.basename(plugin_file)}: #{e.message}"
713+
end
601714
end
602715

603716
$HUNTING_BUDDY = HuntingBuddy.new

0 commit comments

Comments
 (0)