diff --git a/scripts/eloot.lic b/scripts/eloot.lic
index d3b9ad178..1a9f5b686 100644
--- a/scripts/eloot.lic
+++ b/scripts/eloot.lic
@@ -15,9 +15,26 @@
game: Gemstone
tags: loot
required: Lich >= 5.15.0
- version: 2.11.0
+ version: 2.11.1
Improvements:
Major_change.feature_addition.bugfix
+ v2.11.1 (2026-08-05)
+ - feature: add "Locksmith Priority" option (Pool First / Locksmith First) for when
+ both locksmith routes are enabled. Locksmith First tries town first, then pools
+ whatever's left over. Pool First remains the default.
+ - fix: locksmith pool full-container recovery couldn't fire for loot_specials items
+ (orbs, cursed items, keepers, uncommon weapons/armor) since only loot_regular was
+ wired to it; loot_specials now routes through the same recovery.
+ - fix: a completed pool recovery could finalize the box twice (loot_regular returned
+ nil instead of :recovered); both loot_specials and loot_regular now return
+ :recovered and their callers stop before finalizing.
+ - fix: boxes the town locksmith always refuses ("case" boxes) were routed to it
+ anyway, looping forever; process_boxes now keeps them out of the town list and
+ remembers any other box the NPC refuses in-hand for the rest of the run.
+ - fix: trash/drop could sell/discard items marked unsellable in-game; now checks
+ mark status before tossing and stows marked items back instead.
+ - refactor: consolidated 5 duplicated trash/drop-and-check-hand call sites into a
+ single ELoot.toss helper. No behavior change beyond the mark-status fix above.
v2.11.0 (2026-07-03)
- feature: during a locksmith pool return, when all containers are full, sell to
free space and resume looting instead of pausing. The stuck item is returned to
@@ -317,7 +334,7 @@ module ELoot # Data
:urchin_msg, :gemshop_first, :reject_loot_names, :reject_loot_nouns, :version, :debug_logger, :details_check, :coin_bag_full,
:use_house_locker, :che_rooms, :che_entry, :che_exit, :towns, :gambling_kit, :gambling_kit_full, :ready_lines, :weapon_inv, :original_readylist, :blood_band,
:transmog_cache, :loot_exclude_regex, :critter_exclude_regex, :loot_keep_regex, :allowed_special_types, :sell_exclude_regex, :no_vib_regex, :vib_scrolls_regex,
- :pool_command
+ :pool_command, :town_refused
def initialize(settings)
@settings = settings
@@ -404,6 +421,10 @@ module ELoot # Data
# session-scoped cache of ANALYZE results for transmog detection: { [id, name] => true/false }
@transmog_cache = {}
+ # session-scoped set of box names the town locksmith refused to work on while the
+ # box was in hand. Not persisted -- a game update could change what it accepts.
+ @town_refused = []
+
default_crumbly = [
# Kraken Fall
"gnarled dark wooden crook",
@@ -759,6 +780,9 @@ module ELoot # UI Setup
use_standard_tipping: { default: true },
sell_locksmith: { default: false },
sell_locksmith_pool: { default: true },
+ # Only consulted when both sell_locksmith and sell_locksmith_pool are enabled;
+ # decides which is tried first each run. 'pool' preserves pre-existing behavior.
+ locksmith_priority: { default: 'pool' },
locksmith_when_gem_bounty: { default: false },
always_check_pool: { default: false },
locksmith_withdraw_amount: { default: 8000 },
@@ -1105,7 +1129,9 @@ module ELoot # UI Setup
1 0 True False start 10 10 Bank Withdraw Amount 0 3
True True How much to withdraw from the bank before going to the town locksmith. start 10 10 5 locksmith_withdraw_amount_adjustment
0 4 Default to using locksmith when gem bounty active True True False 5 10 True
- 0 2 2 True False Locksmithing 0 0
+ 0 2 2 True False start 10 10 Locksmith Priority
+ 1 3 True False start 10 10 5 Only used when both "Use locksmith pool" and "Use town locksmith" are checked. Whichever is chosen runs first; the other picks up whatever it couldn\'t take. - Pool First
- Locksmith First
+ 1 4 True False Locksmithing 0 0
True False end 5 0 True False start 5 5 2 5 Add 80 True True True start start 5
0 0 Delete 80 True True True end 5 2 0
True True True Scripts between the pool and selling 1 0 325 True True 5 5 5 5 True in True False True True between_store False 0 True
@@ -1435,6 +1461,9 @@ module ELoot # UI Setup
elsif obj.class == Gtk::ComboBoxText
if obj.builder_name =~ /gem_locker|alchemy_locker/
on_locker_load(obj.builder_name)
+ else
+ obj.active_id = @settings[key]
+ obj.signal_connect('changed') { on_update(obj) }
end
elsif obj.class == Gtk::TreeView
next unless treeview_check(key)
@@ -1593,6 +1622,11 @@ module ELoot # UI Setup
end
end
+ # locksmith_priority only means anything with both routes enabled
+ if %w[sell_locksmith_pool sell_locksmith].include?(obj.builder_name)
+ self["locksmith_priority"].sensitive = self["sell_locksmith_pool"].active? && self["sell_locksmith"].active?
+ end
+
if key == :use_standard_tipping && obj.active?
self['use_incremental_tipping'].active = false
@settings[:use_incremental_tipping] = false
@@ -1631,6 +1665,8 @@ module ELoot # UI Setup
@settings[key] = obj.buffer.text.strip
elsif obj.class == Gtk::SpinButton
@settings[key] = obj.buffer.text
+ elsif obj.class == Gtk::ComboBoxText
+ @settings[key] = obj.active_id
end
end
end
@@ -1662,6 +1698,7 @@ module ELoot # UI Setup
disable_all(self["standard_tipping_frame"], exception: self["sell_locksmith_pool"])
disable_all(self["incremental_tipping_frame"], exception: self["sell_locksmith_pool"])
end
+ self["locksmith_priority"].sensitive = self["sell_locksmith_pool"].active? && self["sell_locksmith"].active?
# Skinning Tab
unless self["skin_enable"].active?
@@ -2996,6 +3033,49 @@ module ELoot # Game utility type methods
[GameObj.right_hand.id, GameObj.left_hand.id].include?(obj.id)
end
+ def self.marked_unsellable?(obj)
+ lines = ELoot.get_command("mark ##{obj.id} status", /is not marked as unsellable|has been marked as unsellable/, silent: true, quiet: true)
+ marked = lines.any? { |line| line =~ /has been marked as unsellable/ }
+ ELoot.msg(type: "info", text: " #{obj.name} is marked as unsellable, keeping it.") if marked
+
+ marked
+ end
+
+ # Attempts to trash/drop obj via toss_cmd ("trash" or "drop"), retrying up to
+ # `attempts` times while obj remains in hand. Checks mark status first and
+ # refuses to toss anything marked as unsellable.
+ #
+ # `poll` swaps the roundtime wait for a fast in-hand poll (dump_herbs_junk's
+ # original behavior, for items that don't cause roundtime). `notify_on_keep`
+ # reports when obj survives every attempt.
+ #
+ # Returns true if obj left hand (disposed), false if it's still in hand
+ # (marked, or every attempt failed) - callers should stow it back in that case.
+ def self.toss(obj, toss_cmd, attempts: 1, poll: false, notify_on_keep: false)
+ return false if ELoot.marked_unsellable?(obj)
+
+ attempts.times do
+ break unless ELoot.in_hand?(obj)
+
+ fput("#{toss_cmd} ##{obj.id}")
+
+ if poll
+ 10.times do
+ break unless ELoot.in_hand?(obj)
+ sleep 0.1
+ end
+ else
+ ELoot.wait_rt
+ end
+ end
+
+ return true unless ELoot.in_hand?(obj)
+
+ ELoot.msg(type: "info", text: " #{obj.name} isn't trashed so maybe its special...keeping it.") if notify_on_keep
+
+ false
+ end
+
def self.find_worker
worker = nil
lines = nil
@@ -4875,8 +4955,19 @@ module ELoot # Room looting
end
objs = box.contents
- objs = Loot.loot_specials(objs) unless objs.empty?
- Loot.loot_regular(objs, "Box", box, sell_recovered: sell_recovered, location: location, data: data) unless objs.empty?
+ unless objs.empty?
+ specials = Loot.loot_specials(objs, box: box, location: location, data: data, sell_recovered: sell_recovered)
+ # A recovery already re-looted and trashed/stowed this box via a nested
+ # box_loot; finalizing again would re-issue trash and drag commands.
+ return if specials == :recovered
+
+ objs = specials
+ end
+
+ unless objs.empty?
+ return if Loot.loot_regular(objs, "Box", box, sell_recovered: sell_recovered, location: location, data: data) == :recovered
+ end
+
Sell.save_trash_box(box)
elsif box.type == "plinite"
ELoot.get_res("pluck ##{box.id}", /You carefully pluck/)
@@ -4920,8 +5011,18 @@ module ELoot # Room looting
# Loot the rest of the stuff
objs = box.contents
- objs = Loot.loot_specials(objs) unless objs.empty?
- Loot.loot_regular(objs, "Box", box) unless objs.empty?
+ unless objs.empty?
+ specials = Loot.loot_specials(objs, box: box)
+ # A recovery already re-looted and finalized this box via a nested box_loot;
+ # skip the cleanup below rather than run it against a gone box.
+ next if specials == :recovered
+
+ objs = specials
+ end
+
+ unless objs.empty?
+ next if Loot.loot_regular(objs, "Box", box) == :recovered
+ end
save_box = ELoot.data.settings[:sell_loot_types].include?("box")
valuable_box = /gold|mithril|silver/
@@ -4939,19 +5040,12 @@ module ELoot # Room looting
if save_box && box.name =~ valuable_box && !box.contents.any? { |obj| obj.type =~ /cursed/ }
box.contents.each do |item|
Inventory.drag(item)
- fput("#{toss_cmd} ##{item.id}")
- ELoot.wait_rt
- # if its not gone it might be special - save it
- Inventory.single_drag(item) if ELoot.in_hand?(item)
+ # if it didn't toss, it might be special (or marked) - save it
+ Inventory.single_drag(item) unless ELoot.toss(item, toss_cmd)
end
else
- # Attempt to trash the box up to 4 times
- 4.times do
- break unless ELoot.in_hand?(box)
- fput("#{toss_cmd} ##{box.id}")
- ELoot.wait_rt
- end
+ ELoot.toss(box, toss_cmd, attempts: 4)
end
Inventory.single_drag(box, false) if ELoot.in_hand?(box)
@@ -5015,6 +5109,10 @@ module ELoot # Room looting
end
end
+ # Loot the ordinary items. In a box-looting flow a full-container stow can trigger the
+ # locksmith pool sell-and-resume recovery, which re-loots AND finalizes the whole box.
+ # @return [Symbol, nil] :recovered when that happened -- the caller must stop and must
+ # not finalize the box again; nil otherwise.
def self.loot_regular(objs, from_where = nil, box = nil, sell_recovered: false, location: nil, data: nil)
ELoot.msg(type: "debug", text: "objs: #{objs} | from_where: #{from_where} | sell_recovered: #{sell_recovered} | location: #{location.inspect} | data?: #{!data.nil?}")
@@ -5037,7 +5135,7 @@ module ELoot # Room looting
obj.type =~ loot_cmd_items ? Inventory.single_loot(obj) : Inventory.single_drag(obj)
end
elsif from_where == "Box"
- return if valid.any? { |thing| Loot.stow_box_item(thing, box, location, data, sell_recovered) == :recovered }
+ return :recovered if valid.any? { |thing| Loot.stow_box_item(thing, box, location, data, sell_recovered) == :recovered }
else
valid.each do |thing|
Inventory.single_drag(thing)
@@ -5059,14 +5157,14 @@ module ELoot # Room looting
next false if item.type == 'box'
Loot.stow_box_item(item, box, location, data, sell_recovered) == :recovered
end
- return if recovered
+ return :recovered if recovered
# if there are still box contents the default container has filled up so just do the rest as drags
if box.contents.length.positive?
objs = box.contents
remaining_valid = Loot.valid_objs(objs.clone)
- return if remaining_valid.any? { |thing| Loot.stow_box_item(thing, box, location, data, sell_recovered) == :recovered }
+ return :recovered if remaining_valid.any? { |thing| Loot.stow_box_item(thing, box, location, data, sell_recovered) == :recovered }
end
else
valid.each do |thing|
@@ -5183,8 +5281,26 @@ module ELoot # Room looting
:recovered
end
- def self.loot_specials(objs)
- ELoot.msg(type: "debug", text: "objs: #{objs}")
+ # Loot the items that need individual handling (coins, cursed items, orbs,
+ # uncommon weapons/armor, keepers) and return whatever is left for loot_regular.
+ #
+ # When called from a box-looting flow the caller passes the box context. That makes a
+ # full-container stow eligible for the same locksmith pool sell-and-resume recovery
+ # loot_regular already uses, instead of pausing the script. Without the context (room
+ # looting, critter bags) the behavior is unchanged: stow, and pause if nothing fits.
+ #
+ # @param objs [Array] candidate items
+ # @param box [GameObj, nil] the box being looted; nil outside a box-looting flow
+ # @param location [String, nil] passed through to the recovery
+ # @param data [Hash, nil] passed through to the recovery
+ # @param sell_recovered [Boolean] true when a recovery already ran for this box.
+ # Must be threaded through so the recovery cannot re-enter for the same box.
+ # @return [Array] items left for loot_regular, or
+ # [Symbol] :recovered when a recovery re-looted AND finalized the whole box. The
+ # caller must stop and must not finalize the box again. Only reachable when box is
+ # given, so the no-box callers always get an Array.
+ def self.loot_specials(objs, box: nil, location: nil, data: nil, sell_recovered: false)
+ ELoot.msg(type: "debug", text: "objs: #{objs} | box: #{box.respond_to?(:name) ? box.name : box.inspect} | sell_recovered: #{sell_recovered}")
# Open sacks for looting the room
Inventory.open_loot_containers(objs)
@@ -5193,7 +5309,12 @@ module ELoot # Room looting
uncommon_loot = ["stygian valravn quill", "nacreous disir feather", "silver-veined black draconic idol"]
loot_uncommon_regex = Regexp.union(uncommon_loot)
+ recovered = false
+
objs = objs.reject do |thing|
+ # A recovery re-looted the whole box already; stop touching the stale list.
+ next false if recovered
+
result = Loot.bag_loot(thing) if thing.type =~ /clothing/
next true if result == "crumbly"
@@ -5219,13 +5340,22 @@ module ELoot # Room looting
else
if ELoot.decurse(thing)
Inventory.free_hand
- Inventory.single_drag(thing)
+ if box
+ recovered = (Loot.stow_box_item(thing, box, location, data, sell_recovered) == :recovered)
+ else
+ Inventory.single_drag(thing)
+ end
end
end
next true
end
+ if recovered
+ ELoot.msg(type: "debug", text: "loot_specials: pool recovery re-looted and finalized the box")
+ return :recovered
+ end
+
ELoot.msg(type: "debug", text: "After specials check objs: #{objs}")
return objs
@@ -6341,17 +6471,8 @@ module ELoot # Sells the loot
dump_items.each do |item|
Inventory.drag(item)
- fput("#{toss_cmd} ##{item.id}")
-
- 10.times do
- break unless ELoot.in_hand?(item)
- sleep 0.1
- end
- if ELoot.in_hand?(item)
- ELoot.msg(type: "info", text: " #{item.name} isn't trashed so maybe its special...keeping it.")
- Inventory.single_drag(item)
- end
+ Inventory.single_drag(item) unless ELoot.toss(item, toss_cmd, poll: true, notify_on_keep: true)
end
Inventory.free_hands(both: true)
@@ -6756,6 +6877,9 @@ module ELoot # Sells the loot
if activator # fixme - shouldn't stow both hands just not the box
boxes.each { |box|
+ # A refusal learned earlier in this run applies to the rest of it.
+ next unless Sell.town_openable?(box)
+
Inventory.free_hands(both: true) unless ELoot.in_hand?(box)
Sell.locksmith_open(box, activator)
}
@@ -6777,7 +6901,20 @@ module ELoot # Sells the loot
lines = ELoot.get_command("open ##{box.id}", /That is already open|You open|You throw back|It appears to be locked/, silent: true, quiet: true)
if lines.any?(/locked/)
- res = dothistimeout(activator, 2, /Gimme ([\d,]+) silvers/)
+ res = dothistimeout(activator, 2, /Gimme ([\d,]+) silvers|ignores you/)
+
+ # Ignored while the box is verifiably in hand means the NPC will not work on this
+ # box at all, which is a different failure from a timeout. Remember it so we stop
+ # carrying this box here for the rest of the run.
+ if res =~ /ignores you/ && ELoot.in_hand?(box)
+ Sell.remember_town_refusal(box)
+ ELoot.msg(type: "yellow", space: true,
+ text: " The town locksmith will not open #{box.name}." \
+ " Use the locksmith pool or a player locksmith.")
+ Inventory.single_drag(box, false)
+ return
+ end
+
if res =~ /Gimme ([\d,]+) silvers/
ELoot.data.silver_breakdown["Town Locksmith"] += -1 * $1.delete(",").to_i
ELoot.data.silver_breakdown["Town Open"] += 1
@@ -7121,31 +7258,115 @@ module ELoot # Sells the loot
Inventory.return_hands
end
+ # The town locksmith will not work on "case" boxes -- it ignores them even when the box
+ # is held in hand, so it can only be opened by the locksmith pool or a player locksmith.
+ # Carrying one to town costs a silver withdrawal and a wasted trip every sell run.
+ POOL_ONLY_NOUNS = /\Acase\z/i.freeze unless defined?(POOL_ONLY_NOUNS)
+
+ # @param box [GameObj]
+ # @return [Boolean] false when the town locksmith cannot open this box
+ def self.town_openable?(box)
+ return true unless box.respond_to?(:noun)
+ return false if POOL_ONLY_NOUNS.match?(box.noun.to_s)
+
+ !ELoot.data.town_refused.to_a.include?(box.name)
+ end
+
+ # Safety net for box types not covered by POOL_ONLY_NOUNS. Session-scoped on purpose:
+ # a game update could change what the NPC accepts, so this is not persisted.
+ # @param box [GameObj]
+ def self.remember_town_refusal(box)
+ return unless box.respond_to?(:name)
+
+ (ELoot.data.town_refused ||= []).push(box.name).uniq!
+ ELoot.msg(type: "debug", text: "town_refused: #{ELoot.data.town_refused}")
+ end
+
+ # locksmith_pool exits the script outright when no tipping option is selected, so check
+ # before routing pool-only boxes there.
+ # @return [Boolean]
+ def self.pool_available?
+ ELoot.data.settings[:sell_locksmith_pool] &&
+ !ELoot.f2p? &&
+ (ELoot.data.settings[:use_standard_tipping] || ELoot.data.settings[:use_incremental_tipping])
+ end
+
def self.process_boxes
boxes = ELoot.find_boxes
return unless boxes.any? || ELoot.data.settings[:always_check_pool]
ELoot.msg(type: "debug", text: "length: #{boxes.length}")
- # Process boxes in the locksmithpool
- should_check_pool = ELoot.data.settings[:always_check_pool] || (ELoot.data.settings[:sell_locksmith_pool] && boxes.any?)
- skip_for_gem_bounty = Bounty.task.gem? && ELoot.data.settings[:sell_locksmith] && ELoot.data.settings[:locksmith_when_gem_bounty] && boxes.any?
+ pool_enabled = ELoot.data.settings[:sell_locksmith_pool]
+ town_enabled = ELoot.data.settings[:sell_locksmith]
+ skip_for_gem_bounty = Bounty.task.gem? && town_enabled && ELoot.data.settings[:locksmith_when_gem_bounty] && boxes.any?
- if should_check_pool && !skip_for_gem_bounty
- Sell.locksmith_pool(boxes) if ELoot.data.settings[:sell_locksmith_pool]
- Sell.pool_return # retrieve (and loot) any boxes in the pool
+ # locksmith_priority only has an effect with both routes enabled, and only for the
+ # general case -- the gem bounty setting is a narrower override and keeps its
+ # existing pool-the-unopenable-ones-then-town order regardless of this preference.
+ locksmith_first = !skip_for_gem_bounty && pool_enabled && town_enabled &&
+ ELoot.data.settings[:locksmith_priority] == 'locksmith'
- # Refresh the boxes
- boxes = ELoot.find_boxes
+ if locksmith_first
+ boxes = Sell.route_town_then_pool(boxes)
+ else
+ # Process boxes in the locksmithpool
+ should_check_pool = ELoot.data.settings[:always_check_pool] || (pool_enabled && boxes.any?)
+
+ if should_check_pool && !skip_for_gem_bounty
+ Sell.locksmith_pool(boxes) if pool_enabled
+ Sell.pool_return # retrieve (and loot) any boxes in the pool
+
+ # Refresh the boxes
+ boxes = ELoot.find_boxes
+ elsif boxes.any? { |box| !Sell.town_openable?(box) } && Sell.pool_available?
+ # The gem bounty preference sends boxes to the town locksmith, but it cannot open
+ # these. Pool just those so the rest still go to town as the setting intends.
+ Sell.locksmith_pool(boxes.reject { |box| Sell.town_openable?(box) })
+ Sell.pool_return
+
+ boxes = ELoot.find_boxes
+ end
+
+ # Go to Locksmith for remaining boxes. Never hand it a box it will refuse.
+ town_boxes = boxes.select { |box| Sell.town_openable?(box) }
+ Sell.locksmith(town_boxes) if town_enabled && town_boxes.any?
end
- # Go to Locksmith for remaining boxes
- Sell.locksmith(boxes) if ELoot.data.settings[:sell_locksmith] && boxes.any?
+ # Anything still here can only be opened by the pool or a player locksmith, and the
+ # pool was unavailable (or unneeded) this run. Leave it alone rather than carrying it
+ # nowhere.
+ pool_needed = boxes.reject { |box| Sell.town_openable?(box) }
+ if pool_needed.any?
+ ELoot.msg(type: "yellow", space: true,
+ text: " Keeping #{pool_needed.length} box(es) the town locksmith cannot open: #{pool_needed.map(&:name).join(', ')}." \
+ " Use the locksmith pool or a player locksmith.")
+ end
# Deposit silvers if encumbered
ELoot.silver_deposit if Char.percent_encumbrance > 80
end
+ # "Locksmith First" routing: town locksmith gets whatever it can open, then the pool
+ # mops up whatever's left (including anything a runtime refusal knocked back out of the
+ # town run). Mirrors the shape of the gem-bounty override in process_boxes, just with
+ # town running before the pool instead of after.
+ # @param boxes [Array]
+ # @return [Array] boxes still present after both routes have run
+ def self.route_town_then_pool(boxes)
+ town_boxes = boxes.select { |box| Sell.town_openable?(box) }
+ Sell.locksmith(town_boxes) if town_boxes.any?
+
+ boxes = ELoot.find_boxes
+ if boxes.any? && Sell.pool_available?
+ Sell.locksmith_pool(boxes)
+ Sell.pool_return
+ boxes = ELoot.find_boxes
+ end
+
+ boxes
+ end
+
def self.save_trash_box(box)
ELoot.msg(type: "debug", text: "box: #{box.inspect}")
@@ -7171,20 +7392,12 @@ module ELoot # Sells the loot
if save_box && box.name =~ valuable_box && !box.contents.any? { |obj| obj.type =~ /cursed/ }
box.contents.each do |item|
Inventory.drag(item)
- fput("#{toss_cmd} ##{item.id}")
- ELoot.wait_rt
- # if its not gone it might be special - save it
- Inventory.single_drag(item) if ELoot.in_hand?(item)
+ # if it didn't toss, it might be special (or marked) - save it
+ Inventory.single_drag(item) unless ELoot.toss(item, toss_cmd)
end
else
- # Attempt to trash the box up to 4 times
- 4.times do
- break unless ELoot.in_hand?(box)
-
- fput("#{toss_cmd} ##{box.id}")
- ELoot.wait_rt
- end
+ ELoot.toss(box, toss_cmd, attempts: 4)
end
Inventory.single_drag(box, false) if ELoot.in_hand?(box)
diff --git a/spec/scripts/eloot_spec.rb b/spec/scripts/eloot_spec.rb
index 0c8a9cb63..a92fbbd36 100644
--- a/spec/scripts/eloot_spec.rb
+++ b/spec/scripts/eloot_spec.rb
@@ -91,3 +91,958 @@ def recover?(**overrides)
end
end
end
+
+# RSpec for ELoot::Loot.loot_specials (box-context stow routing).
+#
+# box_loot drains loot_specials before loot_regular, so items that loot_specials handles
+# (orbs, cursed items, keepers, uncommon weapons/armor, clothing) never reached the
+# locksmith pool full-container recovery that v2.11.0 wired into loot_regular alone --
+# they paused the script mid-pool-return instead. These specs pin the routing.
+#
+# Same approach as the predicate spec above: the real method body is extracted from
+# eloot.lic and evaluated into a bare module alongside named stubs for the Lich runtime
+# pieces it touches. Sourcing the shipped method keeps this from drifting; if the source
+# or method cannot be found the spec fails loudly rather than passing on a stale copy.
+
+RSpec.describe 'ELoot::Loot.loot_specials' do
+ let(:eloot_path) do
+ path = [
+ File.expand_path('eloot.lic', __dir__), # delivered alongside the spec
+ File.expand_path('../eloot.lic', __dir__),
+ File.expand_path('../../eloot.lic', __dir__),
+ File.expand_path('../scripts/eloot.lic', __dir__),
+ File.expand_path('../../scripts/eloot.lic', __dir__) # spec/scripts/ -> scripts/
+ ].find { |p| File.exist?(p) }
+ raise "eloot.lic not found (looked relative to #{__dir__})" unless path
+
+ path
+ end
+
+ let(:method_body) do
+ body = File.read(eloot_path)[/^ {4}def self\.loot_specials\b[\s\S]*?^ {4}end$/]
+ raise "loot_specials could not be extracted from #{eloot_path}" unless body
+
+ body
+ end
+
+ # A minimal GameObj stand-in -- only the readers loot_specials touches.
+ let(:obj_class) { Struct.new(:name, :type, :id) }
+
+ def obj(name, type, id = '1')
+ obj_class.new(name, type, id)
+ end
+
+ # Every collaborator call, in order, so the specs can assert on routing rather than
+ # on internal state.
+ let(:calls) { [] }
+
+ # Queued return values for Loot.stow_box_item: nil means the item fit normally,
+ # :recovered means a sell-and-resume recovery re-looted the whole box.
+ let(:stow_results) { [nil] }
+
+ # Mirrors the shipped defaults closely enough to exercise the real branch conditions.
+ let(:allowed_special_types) { %w[box clothing collectible cursed jewelry food breakable] }
+ let(:loot_types) { %w[box clothing collectible coins cursed food gem jewelry magic uncommon valuable] }
+
+ # Named stubs (not a loose double) so an argument-list change in eloot.lic fails here.
+ let(:harness) do
+ recorder = calls
+ queued = stow_results
+ types = loot_types
+ special = allowed_special_types
+
+ data = Object.new
+ data.define_singleton_method(:loot_exclude_regex) { /black ora|urglaes/ }
+ data.define_singleton_method(:loot_keep_regex) { /keepsake/ }
+ data.define_singleton_method(:allowed_special_types) { special }
+ data.define_singleton_method(:settings) { { loot_types: types } }
+ data.define_singleton_method(:charm) { nil }
+
+ eloot = Module.new
+ eloot.define_singleton_method(:data) { data }
+ eloot.define_singleton_method(:msg) { |**_kw| nil }
+ # decurse returns true for anything not cursed; that is the path under test.
+ eloot.define_singleton_method(:decurse) { |_thing| true }
+ eloot.define_singleton_method(:get_res) do |command, _regex|
+ recorder << [:get_res, command]
+ nil
+ end
+
+ inventory = Module.new
+ inventory.define_singleton_method(:open_loot_containers) { |_objs| nil }
+ inventory.define_singleton_method(:free_hand) { nil }
+ inventory.define_singleton_method(:single_drag) do |thing|
+ recorder << [:single_drag, thing.name]
+ nil
+ end
+
+ stats = Module.new
+ stats.define_singleton_method(:level) { 1 }
+
+ loot = Module.new
+ loot.define_singleton_method(:bag_loot) do |thing|
+ recorder << [:bag_loot, thing.name]
+ nil
+ end
+ loot.define_singleton_method(:stow_box_item) do |thing, box, location, data_arg, sell_recovered|
+ recorder << [:stow_box_item, thing.name, box&.name, location, data_arg, sell_recovered]
+ queued.shift
+ end
+
+ mod = Module.new
+ mod.const_set(:ELoot, eloot)
+ mod.const_set(:Inventory, inventory)
+ mod.const_set(:Loot, loot)
+ mod.const_set(:Stats, stats)
+ mod.module_eval(method_body)
+ mod
+ end
+
+ let(:box) { obj('white oak strongbox', 'box', '10244306') }
+ let(:orb) { obj('heavy quartz orb', 'magic', '10244314') } # special: matches the orb rule
+ let(:earcuff) { obj('sunstone earcuff', 'clothing', '10244316') } # special: allowed_special_types
+ let(:mica) { obj('large piece of mica', 'gem', '10244313') } # not special: left for loot_regular
+
+ context 'in a box-looting flow (box context supplied)' do
+ it 'stows through stow_box_item so the pool recovery can fire, not straight to single_drag' do
+ remaining = harness.loot_specials([orb, mica], box: box, location: 'Icemule Trace', data: {}, sell_recovered: false)
+
+ expect(calls).to eq([[:stow_box_item, 'heavy quartz orb', 'white oak strongbox', 'Icemule Trace', {}, false]])
+ expect(remaining).to eq([mica])
+ end
+
+ it 'threads sell_recovered through so a recovery cannot re-enter for the same box' do
+ harness.loot_specials([orb], box: box, location: 'Icemule Trace', data: {}, sell_recovered: true)
+
+ expect(calls.first.last).to be true
+ end
+
+ it 'still routes silver coins to the coin command rather than a stow' do
+ coins = obj('some silver coins', 'coins', '10244308')
+
+ harness.loot_specials([coins], box: box)
+
+ expect(calls).to eq([[:get_res, 'get coins']])
+ end
+
+ it 'still leaves excluded items for loot_regular without touching them' do
+ excluded = obj('black ora bar', 'valuable', '10244309')
+
+ remaining = harness.loot_specials([excluded], box: box)
+
+ expect(calls).to be_empty
+ expect(remaining).to eq([excluded])
+ end
+ end
+
+ context 'when a recovery re-loots the whole box' do
+ let(:stow_results) { [:recovered] }
+
+ it 'reports :recovered rather than an empty list, which would mean "nothing left"' do
+ remaining = harness.loot_specials([orb, earcuff], box: box, location: 'Icemule Trace', data: {})
+
+ expect(remaining).to eq(:recovered)
+ end
+
+ it 'never reports :recovered as a bare empty list, which callers cannot distinguish' do
+ remaining = harness.loot_specials([orb, earcuff], box: box, location: 'Icemule Trace', data: {})
+
+ expect(remaining).not_to eq([])
+ end
+
+ it 'stops processing the stale item list instead of working items twice' do
+ harness.loot_specials([orb, earcuff], box: box, location: 'Icemule Trace', data: {})
+
+ expect(calls.count { |c| c.first == :stow_box_item }).to eq(1)
+ expect(calls).not_to include([:bag_loot, 'sunstone earcuff'])
+ end
+ end
+
+ context 'outside a box-looting flow (room looting, critter bags)' do
+ it 'keeps the existing single_drag behavior and never consults the pool recovery' do
+ remaining = harness.loot_specials([orb, mica])
+
+ expect(calls).to eq([[:single_drag, 'heavy quartz orb']])
+ expect(remaining).to eq([mica])
+ end
+
+ it 'looks inside clothing for critter bags before stowing it' do
+ remaining = harness.loot_specials([earcuff])
+
+ expect(calls).to eq([[:bag_loot, 'sunstone earcuff'], [:single_drag, 'sunstone earcuff']])
+ expect(remaining).to eq([])
+ end
+ end
+end
+
+# RSpec for the box-looting call sites.
+#
+# The v2.11.0 defect was not a broken method -- stow_box_item and pool_full_recovery? were
+# both correct. It was a call site that never routed to them: box_loot drained
+# loot_specials first and passed it no box context. A unit spec on loot_specials cannot
+# catch box_loot forgetting to pass that context, so the wiring is asserted here against
+# the shipped source.
+
+RSpec.describe 'ELoot box-looting call sites' do
+ let(:eloot_path) do
+ path = [
+ File.expand_path('eloot.lic', __dir__), # delivered alongside the spec
+ File.expand_path('../eloot.lic', __dir__),
+ File.expand_path('../../eloot.lic', __dir__),
+ File.expand_path('../scripts/eloot.lic', __dir__),
+ File.expand_path('../../scripts/eloot.lic', __dir__) # spec/scripts/ -> scripts/
+ ].find { |p| File.exist?(p) }
+ raise "eloot.lic not found (looked relative to #{__dir__})" unless path
+
+ path
+ end
+
+ let(:source) { File.read(eloot_path) }
+
+ def method_body(source, name)
+ body = source[/^ {4}def self\.#{Regexp.escape(name)}\b[\s\S]*?^ {4}end$/]
+ raise "#{name} could not be extracted from eloot.lic" unless body
+
+ body
+ end
+
+ let(:box_loot) { method_body(source, 'box_loot') }
+ let(:box_loot_ground) { method_body(source, 'box_loot_ground') }
+
+ it 'box_loot hands the box to loot_specials so a full-container stow can recover' do
+ expect(box_loot).to match(/Loot\.loot_specials\([^)]*\bbox:\s*box\b/)
+ end
+
+ it 'box_loot hands sell_recovered to loot_specials so a recovery cannot re-enter' do
+ expect(box_loot).to match(/Loot\.loot_specials\([^)]*\bsell_recovered:\s*sell_recovered\b/)
+ end
+
+ it 'box_loot hands loot_specials the same location and data it hands loot_regular' do
+ expect(box_loot).to match(/Loot\.loot_specials\([^)]*\blocation:\s*location\b/)
+ expect(box_loot).to match(/Loot\.loot_specials\([^)]*\bdata:\s*data\b/)
+ end
+
+ it 'box_loot_ground hands the box to loot_specials, matching its loot_regular call' do
+ expect(box_loot_ground).to match(/Loot\.loot_specials\([^)]*\bbox:\s*box\b/)
+ end
+
+ # A completed recovery re-loots the box through a nested box_loot, which finalizes it.
+ # Finalizing again re-issues trash and drag commands against a box that is already gone.
+ # box_loot cannot be exercised without the Lich runtime, so the guard is asserted
+ # structurally: the :recovered exits must come before the finalization call.
+ context 'double finalization after a completed recovery' do
+ it 'box_loot returns on :recovered before it reaches Sell.save_trash_box' do
+ guard = box_loot.index(/^ +return if .*== :recovered$/)
+ finalize = box_loot.index(/Sell\.save_trash_box/)
+
+ expect(guard).not_to be_nil, 'box_loot has no :recovered guard'
+ expect(finalize).not_to be_nil, 'box_loot no longer finalizes the box'
+ expect(guard).to be < finalize
+ end
+
+ it 'box_loot guards both the loot_specials and the loot_regular result' do
+ expect(box_loot.scan(/^ +return if .*== :recovered$/).length).to eq(2)
+ end
+
+ it 'box_loot_ground skips to the next box on :recovered before its inline cleanup' do
+ guard = box_loot_ground.index(/^ +next if .*== :recovered$/)
+ cleanup = box_loot_ground.index(/toss_cmd = /)
+
+ expect(guard).not_to be_nil, 'box_loot_ground has no :recovered guard'
+ expect(cleanup).not_to be_nil, 'box_loot_ground no longer cleans up the box'
+ expect(guard).to be < cleanup
+ end
+
+ it 'box_loot_ground guards both the loot_specials and the loot_regular result' do
+ expect(box_loot_ground.scan(/^ +next if .*== :recovered$/).length).to eq(2)
+ end
+
+ it 'loot_regular actually returns the :recovered it documents, not nil' do
+ loot_regular = method_body(source, 'loot_regular')
+
+ expect(loot_regular).to match(/return :recovered if/)
+ expect(loot_regular).not_to match(/^ +return if .*== :recovered$/)
+ end
+ end
+end
+
+# RSpec for ELoot::Sell.town_openable? and the box routing that depends on it.
+#
+# "case" boxes cannot be opened by the town locksmith -- it ignores them even when the box
+# is held in hand -- so only the locksmith pool or a player locksmith can open them.
+# Routing one to town burns a silver withdrawal and a trip on every sell run and the box
+# never opens, so the predicate and the routing are pinned here.
+
+RSpec.describe 'ELoot::Sell.town_openable?' do
+ let(:eloot_path) do
+ path = [
+ File.expand_path('eloot.lic', __dir__), # delivered alongside the spec
+ File.expand_path('../eloot.lic', __dir__),
+ File.expand_path('../../eloot.lic', __dir__),
+ File.expand_path('../scripts/eloot.lic', __dir__),
+ File.expand_path('../../scripts/eloot.lic', __dir__) # spec/scripts/ -> scripts/
+ ].find { |p| File.exist?(p) }
+ raise "eloot.lic not found (looked relative to #{__dir__})" unless path
+
+ path
+ end
+
+ let(:source) { File.read(eloot_path) }
+
+ let(:method_bodies) do
+ %w[town_openable? remember_town_refusal].map do |name|
+ body = source[/^ {4}def self\.#{Regexp.escape(name)}[\s\S]*?^ {4}end$/]
+ raise "#{name} could not be extracted from eloot.lic" unless body
+
+ body
+ end.join("\n\n")
+ end
+
+ let(:pool_only_nouns) do
+ line = source[/^ {4}POOL_ONLY_NOUNS = .*$/]
+ raise 'POOL_ONLY_NOUNS could not be extracted from eloot.lic' unless line
+
+ line
+ end
+
+ let(:obj_class) { Struct.new(:name, :noun) }
+
+ def obj(name, noun)
+ obj_class.new(name, noun)
+ end
+
+ # Fresh per example so a remembered refusal cannot leak between them.
+ let(:refused) { [] }
+
+ let(:harness) do
+ store = refused
+
+ data = Object.new
+ data.define_singleton_method(:town_refused) { store }
+ data.define_singleton_method(:town_refused=) { |v| store.replace(Array(v)) }
+
+ eloot = Module.new
+ eloot.define_singleton_method(:data) { data }
+ eloot.define_singleton_method(:msg) { |**_kw| nil }
+
+ mod = Module.new
+ mod.const_set(:ELoot, eloot)
+ mod.module_eval("#{pool_only_nouns}\n#{method_bodies}")
+ mod.const_set(:Sell, mod)
+ mod
+ end
+
+ context 'the static pool-only rule' do
+ it 'refuses "case" boxes, which the town locksmith ignores even when held' do
+ expect(harness.town_openable?(obj('a gilded delicate case', 'case'))).to be false
+ expect(harness.town_openable?(obj('a crude stained case', 'case'))).to be false
+ end
+
+ it 'still allows the box nouns the town locksmith does open' do
+ %w[box chest coffer strongbox trunk].each do |noun|
+ expect(harness.town_openable?(obj("an acid-pitted steel #{noun}", noun))).to be true
+ end
+ end
+
+ it 'does not refuse a box merely for sharing a case adjective' do
+ expect(harness.town_openable?(obj('a gilded delicate coffer', 'coffer'))).to be true
+ end
+
+ it 'treats an object with no noun as openable rather than raising' do
+ expect(harness.town_openable?(Object.new)).to be true
+ end
+ end
+
+ context 'the learned refusal safety net' do
+ it 'refuses a box name the NPC already ignored this run' do
+ box = obj('a scorched cracked trunk', 'trunk')
+ expect(harness.town_openable?(box)).to be true
+
+ harness.remember_town_refusal(box)
+
+ expect(harness.town_openable?(box)).to be false
+ end
+
+ it 'records each refused name once' do
+ box = obj('a scorched cracked trunk', 'trunk')
+
+ 3.times { harness.remember_town_refusal(box) }
+
+ expect(refused).to eq(['a scorched cracked trunk'])
+ end
+
+ it 'does not refuse other boxes that happen to be present' do
+ harness.remember_town_refusal(obj('a scorched cracked trunk', 'trunk'))
+
+ expect(harness.town_openable?(obj('an acid-pitted steel chest', 'chest'))).to be true
+ end
+ end
+end
+
+RSpec.describe 'ELoot::Sell.process_boxes routing' do
+ let(:eloot_path) do
+ path = [
+ File.expand_path('eloot.lic', __dir__), # delivered alongside the spec
+ File.expand_path('../eloot.lic', __dir__),
+ File.expand_path('../../eloot.lic', __dir__),
+ File.expand_path('../scripts/eloot.lic', __dir__),
+ File.expand_path('../../scripts/eloot.lic', __dir__) # spec/scripts/ -> scripts/
+ ].find { |p| File.exist?(p) }
+ raise "eloot.lic not found (looked relative to #{__dir__})" unless path
+
+ path
+ end
+
+ let(:source) { File.read(eloot_path) }
+
+ def method_body(source, name)
+ body = source[/^ {4}def self\.#{Regexp.escape(name)}\b[\s\S]*?^ {4}end$/]
+ raise "#{name} could not be extracted from eloot.lic" unless body
+
+ body
+ end
+
+ let(:process_boxes) { method_body(source, 'process_boxes') }
+ let(:locksmith) { method_body(source, 'locksmith') }
+ let(:locksmith_open) { method_body(source, 'locksmith_open') }
+
+ # process_boxes cannot be exercised without the Lich runtime, so the routing invariants
+ # are asserted against the shipped source.
+ it 'never hands the raw box list to the town locksmith' do
+ expect(process_boxes).not_to match(/Sell\.locksmith\(boxes\)/)
+ end
+
+ it 'filters the town locksmith list through town_openable?' do
+ expect(process_boxes).to match(/town_boxes = boxes\.select \{ \|box\| Sell\.town_openable\?\(box\) \}/)
+ expect(process_boxes).to match(/Sell\.locksmith\(town_boxes\)/)
+ end
+
+ it 'pools only the pool-only boxes when the gem bounty diverts the rest to town' do
+ expect(process_boxes).to match(/Sell\.locksmith_pool\(boxes\.reject \{ \|box\| Sell\.town_openable\?\(box\) \}\)/)
+ end
+
+ it 'checks the pool is usable before routing pool-only boxes to it' do
+ expect(process_boxes).to match(/Sell\.pool_available\?/)
+ end
+
+ it 'reports boxes it is keeping when the pool was unavailable' do
+ expect(process_boxes).to match(/pool_needed\.any\?/)
+ end
+
+ it 'skips known-refused boxes inside the locksmith activator loop' do
+ expect(locksmith).to match(/next unless Sell\.town_openable\?\(box\)/)
+ end
+
+ it 'distinguishes an in-hand refusal from a timeout and remembers it' do
+ expect(locksmith_open).to match(/ignores you/)
+ expect(locksmith_open).to match(/Sell\.remember_town_refusal\(box\)/)
+ expect(locksmith_open).to match(/ELoot\.in_hand\?\(box\)/)
+ end
+end
+
+# RSpec for the "Locksmith Priority" setting (Pool First / Locksmith First), which only
+# matters when both sell_locksmith and sell_locksmith_pool are enabled. process_boxes'
+# locksmith_first gating is asserted structurally, same as the rest of process_boxes -- it
+# cannot be exercised without the Lich runtime. Sell.route_town_then_pool has no navigation
+# of its own, though, so it is exercised for real against a small stand-in for Sell/ELoot.
+
+RSpec.describe 'ELoot::Sell locksmith_priority routing' do
+ let(:eloot_path) do
+ path = [
+ File.expand_path('eloot.lic', __dir__), # delivered alongside the spec
+ File.expand_path('../eloot.lic', __dir__),
+ File.expand_path('../../eloot.lic', __dir__),
+ File.expand_path('../scripts/eloot.lic', __dir__),
+ File.expand_path('../../scripts/eloot.lic', __dir__) # spec/scripts/ -> scripts/
+ ].find { |p| File.exist?(p) }
+ raise "eloot.lic not found (looked relative to #{__dir__})" unless path
+
+ path
+ end
+
+ let(:source) { File.read(eloot_path) }
+
+ def method_body(source, name)
+ body = source[/^ {4}def self\.#{Regexp.escape(name)}\b[\s\S]*?^ {4}end$/]
+ raise "#{name} could not be extracted from eloot.lic" unless body
+
+ body
+ end
+
+ let(:process_boxes) { method_body(source, 'process_boxes') }
+ let(:route_town_then_pool_body) { method_body(source, 'route_town_then_pool') }
+
+ it 'defaults locksmith_priority to pool, so existing setups keep their current behavior' do
+ line = source[/^\s*locksmith_priority: \{ default: '(\w+)' \},$/, 1]
+ expect(line).to eq('pool')
+ end
+
+ context 'process_boxes locksmith_first gating' do
+ it 'only takes the locksmith-first path with both routes enabled and no gem-bounty override' do
+ expect(process_boxes).to match(/locksmith_first = !skip_for_gem_bounty && pool_enabled && town_enabled &&/)
+ expect(process_boxes).to match(/ELoot\.data\.settings\[:locksmith_priority\] == 'locksmith'/)
+ end
+
+ it 'routes through route_town_then_pool only on the locksmith-first path' do
+ expect(process_boxes).to match(/if locksmith_first\s*\n\s*boxes = Sell\.route_town_then_pool\(boxes\)/)
+ end
+
+ it 'still reports leftover boxes the same way regardless of which path ran' do
+ expect(process_boxes).to match(/pool_needed = boxes\.reject \{ \|box\| Sell\.town_openable\?\(box\) \}/)
+ end
+ end
+
+ context 'route_town_then_pool' do
+ let(:obj_class) { Struct.new(:name, :noun) }
+
+ def obj(name, noun)
+ obj_class.new(name, noun)
+ end
+
+ let(:calls) { [] }
+ let(:town_openable) { {} } # box name => bool, defaults to true
+ let(:find_boxes_queue) { [] } # successive ELoot.find_boxes return values
+ let(:pool_available) { true }
+
+ let(:harness) do
+ log = calls
+ openable = town_openable
+ queue = find_boxes_queue
+ avail = pool_available
+
+ eloot = Module.new
+ eloot.define_singleton_method(:find_boxes) { queue.shift || [] }
+
+ mod = Module.new
+ mod.const_set(:ELoot, eloot)
+ mod.define_singleton_method(:town_openable?) { |box| openable.fetch(box.name, true) }
+ mod.define_singleton_method(:locksmith) { |boxes| log << [:locksmith, boxes.map(&:name)] }
+ mod.define_singleton_method(:pool_available?) do
+ log << [:pool_available?]
+ avail
+ end
+ mod.define_singleton_method(:locksmith_pool) { |boxes| log << [:locksmith_pool, boxes.map(&:name)] }
+ mod.define_singleton_method(:pool_return) { log << [:pool_return] }
+ mod.const_set(:Sell, mod)
+ mod.module_eval(route_town_then_pool_body)
+ mod
+ end
+
+ it 'sends only town-openable boxes to the town locksmith first' do
+ openable_box = obj('a steel strongbox', 'strongbox')
+ pool_only_box = obj('a delicate case', 'case')
+ town_openable[pool_only_box.name] = false
+ find_boxes_queue.replace([[], []])
+
+ harness.route_town_then_pool([openable_box, pool_only_box])
+
+ expect(calls.first).to eq([:locksmith, ['a steel strongbox']])
+ end
+
+ it 'refreshes from the room before deciding what to pool, not the pre-town list' do
+ box = obj('a steel strongbox', 'strongbox')
+ # Still present after the town run (e.g. a mid-run refusal), gone after the pool.
+ find_boxes_queue.replace([[box], []])
+
+ harness.route_town_then_pool([box])
+
+ expect(calls).to include([:locksmith_pool, ['a steel strongbox']])
+ expect(calls).to include([:pool_return])
+ end
+
+ it 'does not visit the pool when nothing is left after town' do
+ box = obj('a steel strongbox', 'strongbox')
+ find_boxes_queue.replace([[]])
+
+ harness.route_town_then_pool([box])
+
+ expect(calls.map(&:first)).not_to include(:locksmith_pool)
+ end
+
+ context 'when the pool is unavailable' do
+ let(:pool_available) { false }
+
+ it 'does not visit the pool' do
+ box = obj('a delicate case', 'case')
+ town_openable[box.name] = false
+ find_boxes_queue.replace([[box]])
+
+ harness.route_town_then_pool([box])
+
+ expect(calls.map(&:first)).not_to include(:locksmith_pool)
+ end
+ end
+
+ it 'returns whatever is still present after both routes have run' do
+ box = obj('a steel strongbox', 'strongbox')
+ leftover = [box]
+ find_boxes_queue.replace([[box], leftover])
+
+ result = harness.route_town_then_pool([box])
+
+ expect(result).to equal(leftover)
+ end
+ end
+end
+
+# RSpec for ELoot.marked_unsellable? and ELoot.toss (the trash/drop helper).
+#
+# box_loot_ground and Sell.save_trash_box each carried their own inline copies of the
+# same trash/drop-and-check-hand logic (once for a single item, once retried up to 4
+# times for a box), and Sell.dump_herbs_junk carried a third copy that polled instead of
+# waiting on roundtime. ELoot.toss consolidates all three into one helper, parameterized
+# on attempts/poll/notify_on_keep, with a mark-status safety check in front of every
+# attempt so a marked item is never sent to trash/drop in the first place.
+#
+# Same approach as the specs above: the real method bodies are extracted from eloot.lic
+# and evaluated into a bare module alongside named stubs for the collaborators they call
+# (ELoot.get_res, ELoot.msg, ELoot.in_hand?, ELoot.wait_rt, and the global fput). Sourcing
+# the shipped methods keeps this from drifting; if the source or a method cannot be found
+# the spec fails loudly rather than passing on a stale copy.
+
+RSpec.describe 'ELoot.marked_unsellable?' do
+ let(:eloot_path) do
+ path = [
+ File.expand_path('eloot.lic', __dir__), # delivered alongside the spec
+ File.expand_path('../eloot.lic', __dir__),
+ File.expand_path('../../eloot.lic', __dir__),
+ File.expand_path('../scripts/eloot.lic', __dir__),
+ File.expand_path('../../scripts/eloot.lic', __dir__) # spec/scripts/ -> scripts/
+ ].find { |p| File.exist?(p) }
+ raise "eloot.lic not found (looked relative to #{__dir__})" unless path
+
+ path
+ end
+
+ let(:source) { File.read(eloot_path) }
+
+ let(:method_body) do
+ body = source[/^ {2}def self\.marked_unsellable\?[\s\S]*?^ {2}end$/]
+ raise "marked_unsellable? could not be extracted from #{eloot_path}" unless body
+
+ body
+ end
+
+ let(:obj_class) { Struct.new(:name, :id) }
+
+ def obj(name, id = '60982408')
+ obj_class.new(name, id)
+ end
+
+ let(:calls) { [] }
+
+ # What the game sends back in response to the "mark - status" command, as the
+ # single matching line get_command would hand back. Each context below overrides
+ # this with one of the two real responses (or nil, for "no matching line found") to
+ # drive the branch under test.
+ let(:response) { nil }
+
+ let(:harness) do
+ recorder = calls
+ reply = response
+
+ mod = Module.new
+ mod.define_singleton_method(:get_command) do |command, _regex, **kw|
+ recorder << [:get_command, command, kw]
+ reply.nil? ? [] : [reply]
+ end
+ mod.define_singleton_method(:msg) { |**kw| recorder << [:msg, kw] }
+ mod.module_eval(method_body)
+ mod.const_set(:ELoot, mod)
+ mod
+ end
+
+ it 'asks for the item mark status by id' do
+ harness.marked_unsellable?(obj('a dark mithril lockpick', '60982408'))
+
+ expect(calls.map { |c| c[0..1] }).to include([:get_command, 'mark #60982408 status'])
+ end
+
+ it 'asks silently, without echoing the command or its response to the player' do
+ harness.marked_unsellable?(obj('a dark mithril lockpick', '60982408'))
+
+ expect(calls.find { |c| c.first == :get_command }.last).to eq(silent: true, quiet: true)
+ end
+
+ context 'when the item is not marked' do
+ let(:response) { 'Your dark mithril lockpick is not marked as unsellable.' }
+
+ it 'returns false' do
+ expect(harness.marked_unsellable?(obj('a dark mithril lockpick'))).to be false
+ end
+
+ it 'does not report anything about it' do
+ harness.marked_unsellable?(obj('a dark mithril lockpick'))
+
+ expect(calls.none? { |c| c.first == :msg }).to be true
+ end
+ end
+
+ context 'when the item has been marked as unsellable' do
+ let(:response) { 'Your sage green silk cloak has been marked as unsellable.' }
+
+ it 'returns true' do
+ expect(harness.marked_unsellable?(obj('a sage green silk cloak'))).to be true
+ end
+
+ it 'reports which item it is keeping' do
+ harness.marked_unsellable?(obj('a sage green silk cloak'))
+
+ expect(calls.last).to eq([:msg, { type: 'info', text: ' a sage green silk cloak is marked as unsellable, keeping it.' }])
+ end
+ end
+
+ context 'when no line matches (lag, an unrelated line, a timeout)' do
+ let(:response) { nil }
+
+ it 'fails open rather than blocking a legitimate toss' do
+ expect(harness.marked_unsellable?(obj('a dark mithril lockpick'))).to be false
+ end
+ end
+end
+
+RSpec.describe 'ELoot.toss' do
+ let(:eloot_path) do
+ path = [
+ File.expand_path('eloot.lic', __dir__), # delivered alongside the spec
+ File.expand_path('../eloot.lic', __dir__),
+ File.expand_path('../../eloot.lic', __dir__),
+ File.expand_path('../scripts/eloot.lic', __dir__),
+ File.expand_path('../../scripts/eloot.lic', __dir__) # spec/scripts/ -> scripts/
+ ].find { |p| File.exist?(p) }
+ raise "eloot.lic not found (looked relative to #{__dir__})" unless path
+
+ path
+ end
+
+ let(:source) { File.read(eloot_path) }
+
+ let(:method_body) do
+ body = source[/^ {2}def self\.toss\b[\s\S]*?^ {2}end$/]
+ raise "toss could not be extracted from #{eloot_path}" unless body
+
+ body
+ end
+
+ let(:obj_class) { Struct.new(:name, :id) }
+ let(:item) { obj_class.new('a rusty dagger', '12345') }
+
+ let(:fput_calls) { [] }
+ let(:wait_rt_calls) { [] }
+
+ # The item "leaves hand" once fput has been called `disposed_after` times. A value
+ # larger than `attempts` means it never leaves hand within the attempts under test.
+ let(:disposed_after) { 1 }
+ let(:marked) { false }
+
+ let(:harness) do
+ fputs = fput_calls
+ waits = wait_rt_calls
+ need = disposed_after
+ is_marked = marked
+
+ mod = Module.new
+ mod.define_singleton_method(:marked_unsellable?) { |_obj| is_marked }
+ mod.define_singleton_method(:fput) { |cmd| fputs << cmd }
+ mod.define_singleton_method(:in_hand?) { |_obj| fputs.length < need }
+ mod.define_singleton_method(:wait_rt) { waits << true }
+ mod.module_eval(method_body)
+ mod.const_set(:ELoot, mod)
+ mod
+ end
+
+ context 'when the item is marked as unsellable' do
+ let(:marked) { true }
+
+ it 'never attempts to toss it' do
+ harness.toss(item, 'trash')
+
+ expect(fput_calls).to be_empty
+ end
+
+ it 'returns false so the caller stows it back' do
+ expect(harness.toss(item, 'trash')).to be false
+ end
+ end
+
+ context 'when unmarked and disposed on the first attempt' do
+ it 'issues the toss command with the item id' do
+ harness.toss(item, 'trash')
+
+ expect(fput_calls).to eq(['trash #12345'])
+ end
+
+ it 'uses the given toss_cmd verbatim (drop vs trash)' do
+ harness.toss(item, 'drop')
+
+ expect(fput_calls).to eq(['drop #12345'])
+ end
+
+ it 'returns true' do
+ expect(harness.toss(item, 'trash')).to be true
+ end
+
+ it 'waits on roundtime by default' do
+ harness.toss(item, 'trash')
+
+ expect(wait_rt_calls.length).to eq(1)
+ end
+ end
+
+ context 'when unmarked but still in hand after the only attempt (attempts: 1 default)' do
+ let(:disposed_after) { 2 }
+
+ it 'returns false' do
+ expect(harness.toss(item, 'trash')).to be false
+ end
+
+ it 'only tries once' do
+ harness.toss(item, 'trash')
+
+ expect(fput_calls.length).to eq(1)
+ end
+
+ it 'stays silent by default (notify_on_keep: false)' do
+ recorder = fput_calls
+ mod = harness
+ msgs = []
+ mod.define_singleton_method(:msg) { |**kw| msgs << kw }
+
+ mod.toss(item, 'trash')
+
+ expect(msgs).to be_empty
+ expect(recorder.length).to eq(1) # sanity: the attempt still happened
+ end
+
+ it 'reports it when notify_on_keep is true' do
+ mod = harness
+ msgs = []
+ mod.define_singleton_method(:msg) { |**kw| msgs << kw }
+
+ mod.toss(item, 'trash', notify_on_keep: true)
+
+ expect(msgs).to eq([{ type: 'info', text: " #{item.name} isn't trashed so maybe its special...keeping it." }])
+ end
+ end
+
+ context 'with attempts: 4 (the box-toss retry)' do
+ context 'and it never leaves hand' do
+ let(:disposed_after) { 99 }
+
+ it 'tries exactly 4 times, no more' do
+ harness.toss(item, 'trash', attempts: 4)
+
+ expect(fput_calls.length).to eq(4)
+ end
+
+ it 'returns false' do
+ expect(harness.toss(item, 'trash', attempts: 4)).to be false
+ end
+ end
+
+ context 'and it leaves hand on the 3rd attempt' do
+ let(:disposed_after) { 3 }
+
+ it 'stops retrying once it is gone, instead of always spending all 4' do
+ harness.toss(item, 'trash', attempts: 4)
+
+ expect(fput_calls.length).to eq(3)
+ end
+
+ it 'returns true' do
+ expect(harness.toss(item, 'trash', attempts: 4)).to be true
+ end
+ end
+ end
+
+ context 'with poll: true (dump_herbs_junk\'s fast in-hand poll)' do
+ it 'never waits on roundtime' do
+ harness.toss(item, 'trash', poll: true)
+
+ expect(wait_rt_calls).to be_empty
+ end
+
+ it 'still disposes correctly' do
+ expect(harness.toss(item, 'trash', poll: true)).to be true
+ end
+
+ context 'when the item survives' do
+ let(:disposed_after) { 2 }
+
+ it 'still returns false' do
+ expect(harness.toss(item, 'trash', poll: true)).to be false
+ end
+ end
+ end
+
+ it 'checks mark status before ever attempting a toss' do
+ mark_check = method_body.index(/marked_unsellable\?/)
+ retry_loop = method_body.index(/attempts\.times/)
+
+ expect(mark_check).not_to be_nil
+ expect(retry_loop).not_to be_nil
+ expect(mark_check).to be < retry_loop
+ end
+end
+
+# RSpec for the trash/drop call-site refactor (box_loot_ground, Sell.save_trash_box,
+# Sell.dump_herbs_junk). Before this, all 5 call sites carried their own copy of the
+# fput/wait/check-hand sequence; a unit spec on ELoot.toss alone cannot catch a call site
+# drifting back to an inline copy, so the wiring is pinned here against the shipped
+# source, the same way the box-looting call sites above are pinned.
+
+RSpec.describe 'ELoot trash/drop call sites' do
+ let(:eloot_path) do
+ path = [
+ File.expand_path('eloot.lic', __dir__), # delivered alongside the spec
+ File.expand_path('../eloot.lic', __dir__),
+ File.expand_path('../../eloot.lic', __dir__),
+ File.expand_path('../scripts/eloot.lic', __dir__),
+ File.expand_path('../../scripts/eloot.lic', __dir__) # spec/scripts/ -> scripts/
+ ].find { |p| File.exist?(p) }
+ raise "eloot.lic not found (looked relative to #{__dir__})" unless path
+
+ path
+ end
+
+ let(:source) { File.read(eloot_path) }
+
+ # Indent-agnostic: box_loot_ground/save_trash_box/dump_herbs_junk sit at different
+ # nesting depths (Loot vs Sell vs top-level ELoot), unlike the fixed-indent helper
+ # used elsewhere in this file.
+ def method_body(source, name)
+ match = source.match(/^( +)def self\.#{Regexp.escape(name)}\b[\s\S]*?\n\1end$/)
+ raise "#{name} could not be extracted from eloot.lic" unless match
+
+ match[0]
+ end
+
+ let(:box_loot_ground) { method_body(source, 'box_loot_ground') }
+ let(:save_trash_box) { method_body(source, 'save_trash_box') }
+ let(:dump_herbs_junk) { method_body(source, 'dump_herbs_junk') }
+
+ it 'routes every trash/drop call site through the shared helper' do
+ expect(source.scan(/ELoot\.toss\(/).length).to eq(5)
+ end
+
+ # Single-quoted deliberately: this is a literal substring match against the source
+ # text, not interpolation. Double-quoting would try to interpolate a `toss_cmd`
+ # local that doesn't exist in this spec and raise NameError.
+ it 'leaves the raw toss command in exactly one place: inside the helper itself' do
+ expect(source.scan('fput("#{toss_cmd}').length).to eq(1) # rubocop:disable Lint/InterpolationCheck
+ end
+
+ it 'box_loot_ground tosses box contents with the 1-attempt default, and the box itself with 4' do
+ expect(box_loot_ground).to match(/Inventory\.single_drag\(item\) unless ELoot\.toss\(item, toss_cmd\)/)
+ expect(box_loot_ground).to match(/ELoot\.toss\(box, toss_cmd, attempts: 4\)/)
+ end
+
+ it 'save_trash_box tosses box contents with the 1-attempt default, and the box itself with 4' do
+ expect(save_trash_box).to match(/Inventory\.single_drag\(item\) unless ELoot\.toss\(item, toss_cmd\)/)
+ expect(save_trash_box).to match(/ELoot\.toss\(box, toss_cmd, attempts: 4\)/)
+ end
+
+ it 'dump_herbs_junk keeps its fast poll and keep-notification behavior' do
+ expect(dump_herbs_junk).to match(/ELoot\.toss\(item, toss_cmd, poll: true, notify_on_keep: true\)/)
+ end
+end