Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 5 additions & 19 deletions scripts/bigshot.lic
Original file line number Diff line number Diff line change
Expand Up @@ -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 <crtrStatus> 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 <crtrStatus> 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 <crtrStatus immobile="1"> 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 <crtrStatus> 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 <crtrStatus> 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 <crtrStatus> 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)
Expand Down Expand Up @@ -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
Expand Down
214 changes: 214 additions & 0 deletions spec/bigshot/looting_watch_spec.rb
Original file line number Diff line number Diff line change
@@ -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