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 10TrueFalsestart1010Bank Withdraw Amount03 TrueTrueHow much to withdraw from the bank before going to the town locksmith.start10105locksmith_withdraw_amount_adjustment 04Default to using locksmith when gem bounty activeTrueTrueFalse510True - 022TrueFalseLocksmithing00 + 022TrueFalsestart1010Locksmith Priority + 13TrueFalsestart10105Only 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 FirstLocksmith First + 14TrueFalseLocksmithing00 TrueFalseend50TrueFalsestart5525Add80TrueTrueTruestartstart5 00Delete80TrueTrueTrueend520 TrueTrueTrueScripts between the pool and selling10325TrueTrue5555TrueinTrueFalseTrueTruebetween_storeFalse0True @@ -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