diff --git a/scripts/bigshot.lic b/scripts/bigshot.lic index 245615e05..beefb84ce 100644 --- a/scripts/bigshot.lic +++ b/scripts/bigshot.lic @@ -18,25 +18,10 @@ Version Control: Major_change.feature_addition.bugfix v5.16.0 (2026-08-13) - - migrate room-creature targeting from GameObj.targets/.npcs to the Lich::Gemstone::Creature / Combat::Tracker APIs (Lich >= 5.19.0). GameObj.targets is built from XMLData.current_target_ids (the client's target dropdown), which can go stale after a kill or a room change; Creature reads the room roster instead - - add BigshotCreature, an adapter wrapping a CreatureInstance so the cmd_* methods keep their GameObj-shaped .id/.status/.type. The Integer creature id is normalized to String at the boundary - - replace the npc.status =~ /dead|gone/ idiom across ~40 call sites with dead_or_gone?, reading the dead flag plus GameObj's status string - - enable Combat::Tracker at startup (persists per character; enable! is a no-op when already on) - - add command checks for crtrStatus statuses: calm, disoriented, hovering, immobilized, kneeling, sitting, sleeping, stunned, webbed - - add command checks for crtrStatus classification flags: ascended, ascension_boss, challenging, disengaged, inferior, mini_boss, mount, rider, sympathetic - - add command checks for Combat::Tracker data: wounded, fatalcrit, smote, ucsdecent, ucsgood, ucsexcellent, ucstierup. These are additive and do not alter $bigshot_unarmed_tier or any existing tier/targeting logic - - migrate flying and rooted from status-string regex to native has_status? reads - - Deliberate limits on the migration, verified against lich-5 source: - - targets come from bs_hostile_creatures (room roster filtered on hostile and not dead), NOT Creature.targets. Creature.targets selects on CreatureInstance#valid_target?, which is false once #dead? is true - and #dead? means max_hp - damage_taken <= 0, not the game's dead flag. damage_taken accumulates from every damage number Combat::Tracker parses and is never reset (Creature#reset_damage exists but nothing in lich-5 calls it), and max_hp falls back to Tracker.fallback_hp or a hardcoded 400 for the 131 of 611 bundled templates carrying max_hp: nil. Since this release both enables the tracker and makes this the only source of targets, trusting #dead? would drop a live creature from the target list once its estimated damage crossed a guessed ceiling - and group hunting over-attributes damage, because every member's damage on a shared target is parsed from every member's own feed - - bs_hostile_creatures reimplements valid_target?'s other two exclusions (animated decoys, appendage nouns) rather than inheriting them, mirroring lib/gemstone/creature.rb. If that upstream regex gains a case, mirror it here - - bs_hostile_creatures also bridges in GameObj.targets entries that Creature has no instance for. bandit_track manufactures its quarry with GameObj.new_npc after scraping a manual look, because bandits never appear in the room feed, so Creature.register is never called for them and they could not carry crtr_flag?(:hostile) regardless. Scoped to ids Creature has never seen, so ordinary creatures still come from the fresh roster - - the frozen command check reads the immobilized status natively. "frozen" is the same state the feed calls immobilized: lich-5 maps to the canonical immobilized status, and the message parser reaches it from the entangling/restricting-force messages. No immobile="1" appears in the GS4 logs sampled here, so the room-text rendering is unverified; reading it natively removes the dependency on that rendering. The /frozen/i string match is retained as a fallback, so the check is a strict superset of the old one; polarity is unchanged - - read creature statuses from rather than the GameObj status string. A creature can hold several statuses at once, but GameObj.status is single-valued - the parser assigns one capture from the room annotation - and the game renders only the highest-precedence one. Measured over ~1000 creature observations from real GS4 logs: prone alone renders "that is lying down.", prone + stunned renders "that appears stunned.", prone + dead renders "that appears dead.". This masking is why the new individual status modifiers (stunned, sleeping, sitting, calm) read natively; a creature holding two statuses only ever advertises one - - npc_prone? likewise reads sleeping/webbed/stunned/kneeling/sitting/prone/immobilized natively, keeping the PRONE regex as a fallback for npcs with no CreatureInstance behind them. Note this is a robustness change, not a bug fix: across those same logs the old regex and the native read agree on every live creature, because each masking status observed is itself in PRONE and "lying down" matches ^lying. The one case the string cannot see is dead + prone, which dead_or_gone? gates first. Polarity is unchanged - - .type stays GameObj-backed permanently: GameObj#type (undead, noncorporeal, aggressive npc, companion, familiar, boon, escort) comes from the static @@type_data lookup table with no equivalent, so the undead and noncorporeal command checks are unchanged - - room-presence checks stay on GameObj.npcs (loot/need_to_loot?, should_flee?'s ALWAYS_FLEE_FROM, the bounty child-rescue check). Creature only registers a room object when a live arrives or its id is in the target dropdown, and clear_room fires on every room-objs refresh, so a corpse or a named-but-not-hostile NPC can be present while absent from Creature.in_room - - leader_target? still sources the id from GameObj.target (the client's single selected target has no Creature equivalent) but wraps the result, falling back to the raw GameObj when no registry entry exists + - creature tracking is more accurate right after a kill or a room change, so targeting and looting are less likely to grab a stale target + - added checks for more creature states (calm, disoriented, hovering, immobilized, kneeling, sitting, sleeping, stunned, webbed, flying, rooted) and creature types (mounts, riders, mini-bosses, and more), so combat commands react to them correctly + - added checks for wounds, critical hits, and smites, so combat maneuvers can respond to them + - fixed a rare timing issue where looting could get interrupted mid-action if bigshot and the loot script were paused and resumed at nearly the same moment (e.g. by ecleanse), sometimes skipping or cutting off a loot/skin action v5.15.4 (2026-08-05) - add additional messaging for cmd_unravel v5.15.3 (2026-07-29) @@ -7835,6 +7820,7 @@ class Bigshot # @return [void] def looting_watch(script_ran) loop do + Script.current break if Script.paused?(script_ran.name) break if !Script.running?(script_ran.name) break if $bigshot_should_rest diff --git a/spec/bigshot/looting_watch_spec.rb b/spec/bigshot/looting_watch_spec.rb new file mode 100644 index 000000000..c9afdd2c3 --- /dev/null +++ b/spec/bigshot/looting_watch_spec.rb @@ -0,0 +1,214 @@ +# Spec for bigshot.lic's looting_watch pause handling. +# +# We do NOT load the .lic file: it needs the whole Lich runtime (Settings, +# XMLData, Spell, GTK, DRb ...). Instead the real method body is extracted +# from scripts/bigshot.lic and evaluated against stubs (same technique as +# spec/bigshot/priority_spec.rb), so this spec exercises production code and +# fails if that code's shape changes. +# +# Regression covered: looting_watch used to check only the *watched* script's +# (eloot's) pause state, never bigshot's own. When something (e.g. ecleanse) +# paused bigshot and eloot back-to-back, looting_watch would notice eloot's +# pause and return to its caller immediately, without regard for bigshot's +# own pause - letting bigshot's thread run one more loop iteration and race +# eloot's independently-resumed thread over the next corpse. The fix adds +# `Script.current` at the top of the loop, which is the idiom lich-5 uses +# elsewhere (echo, fput, ...) to block on the calling script's own pause. +# +# The stub Script.current models that blocking with a real Mutex/ +# ConditionVariable rather than sleep-based polling, and the spec +# rendezvouses with the background thread through Queues so the assertions +# are deterministic instead of racing real wall-clock timing. + +module BigshotLootingWatchSpec + SOURCE_PATH = File.expand_path('../../scripts/bigshot.lic', __dir__) + SOURCE = File.read(SOURCE_PATH).gsub("\r\n", "\n") + + LOOTING_WATCH_SRC = SOURCE[/^ def looting_watch\(script_ran\).*?^ end$/m] or + raise 'could not extract looting_watch from bigshot.lic' + + ScriptRef = Struct.new(:name) + Item = Struct.new(:type) + + # Stand-in for Lich's Script class. #current models bigshot's own pause + # enforcement (Script#wait_while_paused!) with a Mutex/ConditionVariable: + # it blocks while the "self" script (bigshot) is paused, and pushes onto + # an optional queue right before it starts waiting, so a spec can + # rendezvous with that moment instead of guessing at timing. + class ScriptRegistry + def initialize(self_name:) + @self_name = self_name + @mutex = Mutex.new + @cv = ConditionVariable.new + @entries = {} + @current_calls = 0 + @killed = [] + end + + attr_accessor :waiting_queue + attr_reader :current_calls, :killed + + def add(name, paused: false, running: true) + @entries[name] = { paused: paused, running: running } + end + + def pause(name) + @mutex.synchronize { @entries.fetch(name)[:paused] = true } + end + + def unpause(name) + @mutex.synchronize do + @entries.fetch(name)[:paused] = false + @cv.broadcast + end + end + + def paused?(name) + @entries.fetch(name)[:paused] + end + + def running?(name) + @entries.fetch(name)[:running] + end + + def kill(name) + @entries.fetch(name)[:running] = false + @killed << name + end + + def current + @mutex.synchronize do + @current_calls += 1 + if @entries.fetch(@self_name)[:paused] + @waiting_queue&.push(:waiting) + @cv.wait(@mutex) while @entries.fetch(@self_name)[:paused] + end + end + self + end + end + + module Harness + module GameObj + class << self + attr_accessor :right_hand, :left_hand + end + end + + module Script + class << self + attr_accessor :registry + + def current + registry.current + end + + def paused?(name) + registry.paused?(name) + end + + def running?(name) + registry.running?(name) + end + + def kill(name) + registry.kill(name) + end + end + end + + class Runner + eval(BigshotLootingWatchSpec::LOOTING_WATCH_SRC) + end + end +end + +RSpec.describe 'bigshot looting_watch' do + include BigshotLootingWatchSpec + + let(:registry) { BigshotLootingWatchSpec::ScriptRegistry.new(self_name: 'bigshot') } + let(:eloot) { BigshotLootingWatchSpec::ScriptRef.new('eloot') } + let(:runner) { BigshotLootingWatchSpec::Harness::Runner.new } + + before do + registry.add('bigshot') + registry.add('eloot') + BigshotLootingWatchSpec::Harness::Script.registry = registry + BigshotLootingWatchSpec::Harness::GameObj.right_hand = BigshotLootingWatchSpec::Item.new('') + BigshotLootingWatchSpec::Harness::GameObj.left_hand = BigshotLootingWatchSpec::Item.new('') + $bigshot_should_rest = false + $rest_reason = nil + end + + after do + $bigshot_should_rest = false + $rest_reason = nil + end + + it 'blocks on its own (bigshot) pause even while the watched script is also paused, ' \ + 'and only returns once bigshot is unpaused' do + registry.pause('bigshot') + registry.pause('eloot') + + waiting = Queue.new + returned = Queue.new + registry.waiting_queue = waiting + + thread = Thread.new do + runner.looting_watch(eloot) + returned.push(:done) + end + + waiting.pop # blocks until the thread is parked inside Script.current + + expect(returned).to be_empty + expect(registry.killed).to be_empty + + registry.unpause('bigshot') + returned.pop # blocks until looting_watch actually returns + + thread.join + # eloot is paused but idle (no box in hand, no rest flag), so the loop + # exits without killing it - matches the pre-fix behavior for this half + # of the condition once bigshot's own pause is no longer in the way. + expect(registry.killed).to be_empty + end + + it 'still breaks immediately on the watched script pausing when bigshot itself was never paused' do + registry.pause('eloot') + + runner.looting_watch(eloot) + + expect(registry.current_calls).to eq(1) + expect(registry.killed).to be_empty + end + + it 'kills the watched script when it stops running, independent of pause state' do + registry.unpause('eloot') + registry.kill('eloot') + + runner.looting_watch(eloot) + + expect(registry.current_calls).to eq(1) + end + + it 'sets $bigshot_should_rest and the box reason when a box is in hand and eloot is paused' do + registry.pause('eloot') + BigshotLootingWatchSpec::Harness::GameObj.right_hand = BigshotLootingWatchSpec::Item.new('box') + + runner.looting_watch(eloot) + + expect($bigshot_should_rest).to be true + expect($rest_reason).to eq("Box in hand, couldn't store") + expect(registry.killed).to eq(['eloot']) + end + + it 'kills the watched script when $bigshot_should_rest is already set and it is still running' do + registry.unpause('eloot') + $bigshot_should_rest = true + + runner.looting_watch(eloot) + + expect(registry.killed).to eq(['eloot']) + end +end