From 0a13d45fb94ca9c48061a74ec0829a9e3fa6325d Mon Sep 17 00:00:00 2001 From: mrhoribu <15917743+mrhoribu@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:13:03 -0400 Subject: [PATCH 1/2] fix(bigshot.lic): v5.16.1 looting_watch respects bigshot's own pause state looting_watch only checked the watched script's (eloot's) pause state, never bigshot's own. When something paused bigshot and eloot back to back (e.g. ecleanse pausing a script list for a disarm-recovery cast), looting_watch noticed eloot's pause and returned to its caller immediately, regardless of whether bigshot itself had also just been paused. That let bigshot's thread advance to the next corpse's run_script/Script.kill one iteration early, racing eloot's own independently-resumed thread with no reliable ordering. Add Script.current at the top of the loop - the same idiom lich-5 uses elsewhere (echo, fput, ...) to block on the calling script's own pause via Script#wait_while_paused! - so bigshot's own pause is checked on every iteration, not just at entry. Co-Authored-By: Claude Sonnet 5 --- scripts/bigshot.lic | 5 +- spec/bigshot/looting_watch_spec.rb | 214 +++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 spec/bigshot/looting_watch_spec.rb diff --git a/scripts/bigshot.lic b/scripts/bigshot.lic index 245615e05..c36dd4612 100644 --- a/scripts/bigshot.lic +++ b/scripts/bigshot.lic @@ -8,7 +8,7 @@ contributors: SpiffyJr, Tillmen, Kalros, Hazado, Tysong, Athias, Falicor, Deysh, Nisugi game: Gemstone tags: hunting, bigshot, combat - version: 5.16.0 + version: 5.16.1 required: Lich >= 5.19.0 Setup Instructions: https://gswiki.play.net/Script_Bigshot @@ -17,6 +17,8 @@ Version Control: Major_change.feature_addition.bugfix + v5.16.1 (2026-08-26) + - fix looting_watch racing eloot on resume: the loop only checked eloot's own pause state, so it would return control to run_script/loot the instant eloot paused, even if bigshot itself had also just been paused by the same caller (e.g. ecleanse pausing bigshot, eloot, and volnrestore in sequence for a disarm-recovery cast). That let bigshot's thread run one more loot iteration and race eloot's own resumed thread over the next corpse's Script.kill, with unpredictable ordering. looting_watch now calls Script.current each iteration, which resolves bigshot's own pause state and blocks there via Script#wait_while_paused! before the loop's break conditions are evaluated 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 @@ -7835,6 +7837,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 From 5ba676b3ca4e39c6b4051456ab735e2af85c7242 Mon Sep 17 00:00:00 2001 From: mrhoribu <15917743+mrhoribu@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:27:55 -0400 Subject: [PATCH 2/2] chore(bigshot.lic): fold pause-race fix into v5.16.0, simplify changelog Keep this fix landing as part of the v5.16.0 release the rest of the stacked PRs are shipping, rather than a separate v5.16.1. Also rewrites the v5.16.0 changelog entries (including this one) in plain, player-facing language instead of implementation detail. Co-Authored-By: Claude Sonnet 5 --- scripts/bigshot.lic | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/scripts/bigshot.lic b/scripts/bigshot.lic index c36dd4612..beefb84ce 100644 --- a/scripts/bigshot.lic +++ b/scripts/bigshot.lic @@ -8,7 +8,7 @@ contributors: SpiffyJr, Tillmen, Kalros, Hazado, Tysong, Athias, Falicor, Deysh, Nisugi game: Gemstone tags: hunting, bigshot, combat - version: 5.16.1 + version: 5.16.0 required: Lich >= 5.19.0 Setup Instructions: https://gswiki.play.net/Script_Bigshot @@ -17,28 +17,11 @@ Version Control: Major_change.feature_addition.bugfix - v5.16.1 (2026-08-26) - - fix looting_watch racing eloot on resume: the loop only checked eloot's own pause state, so it would return control to run_script/loot the instant eloot paused, even if bigshot itself had also just been paused by the same caller (e.g. ecleanse pausing bigshot, eloot, and volnrestore in sequence for a disarm-recovery cast). That let bigshot's thread run one more loot iteration and race eloot's own resumed thread over the next corpse's Script.kill, with unpredictable ordering. looting_watch now calls Script.current each iteration, which resolves bigshot's own pause state and blocks there via Script#wait_while_paused! before the loop's break conditions are evaluated 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)