Module: Lich::Util

Includes:
Enumerable
Defined in:
documented/util/deep_freeze.rb,
documented/update.rb,
documented/util/opts.rb,
documented/util/util.rb,
documented/magic-info.rb,
documented/util/textstripper.rb,
documented/util/gtk_compaction.rb,
documented/util/memoryreleaser.rb,
documented/common/update/file_writer.rb,
documented/common/update/script_sync.rb,
documented/common/update/custom_repos.rb,
documented/common/update/file_updater.rb,
documented/common/update/github_client.rb,
documented/common/update/status_reporter.rb,
documented/common/update/tracked_scripts.rb,
documented/common/update/branch_installer.rb,
documented/common/update/channel_resolver.rb,
documented/common/update/snapshot_manager.rb,
documented/common/update/release_installer.rb

Overview

Namespace for Lich utility modules and classes.

See Also:

Defined Under Namespace

Modules: GtkCompaction, Magicinfo, MemoryReleaser, TextStripper, Update Classes: Opts

Constant Summary collapse

QUIET_STATE_TAGS =

Self-closing tags that toggle persistent client display state (as opposed to e.g. , which self-heals on the next prompt). The raw server chunk handed to a DownstreamHook proc is not split on line boundaries, so the server is free to bundle one of these onto the same chunk as text that quiet: true is about to drop. Silently discarding that chunk would discard the tag with it -- e.g. dropping a trailing mono-closing that rode in on the same chunk as the a quiet-filtered range ends on leaves the frontend stuck in mono mode until an unrelated, later mono tag happens to close it.

Only the mono/formatting tag is confirmed to hit this in practice today. The list is deliberately an array of patterns (not a single regex) so a future confirmed case -- e.g. pushBold/popBold or pushStream/popStream -- can be added as its own entry without touching the filtering logic below. Keep each pattern anchored to a single self-closing <tag .../>, with no capturing groups (String#scan returns captured subgroups instead of full matches when a pattern has any, which would silently change what preserve_quiet_state_tags returns), so a scan can't accidentally absorb surrounding text or change shape.

[
  /<output class="[^"]*"\s*\/>/ # mono/formatting state toggle
].freeze
QUIET_STATE_TAG_PATTERN =

Combines QUIET_STATE_TAGS into a single alternation, scanned once per chunk. Scanning each pattern separately and concatenating the results (the original approach) groups matches by pattern rather than by where they actually appear -- invisible with a single pattern, but as soon as a second entry is added, two tags from different patterns on the same chunk would come back in pattern order instead of source order, which matters when the tags are an open/close pair. A single combined scan preserves the chunk's real left-to-right order regardless of how many patterns are registered.

Regexp.union(QUIET_STATE_TAGS).freeze

Class Method Summary collapse

Class Method Details

.anon_hook(prefix = '') ⇒ String

Generates a unique anonymous hook identifier string.

Examples:

Util.anon_hook('event') #=> "Util::event-2024-06-13 12:34:56 +0000-1234"

Parameters:

  • prefix (String) (defaults to: '')

    an optional prefix to include in the identifier (default: '')

Returns:

  • (String)

    a unique identifier in the format "Util::--<random_number>"



71
72
73
74
# File 'documented/util/util.rb', line 71

def self.anon_hook(prefix = '')
  now = Time.now
  "Util::#{prefix}-#{now}-#{Random.rand(10000)}"
end

.deep_freeze(value) ⇒ Object

Recursively freezes Arrays, Hashes, and their nested contents.

Parameters:

  • value (Object)

    object to freeze

Returns:

  • (Object)

    the original object after recursive freezing



11
12
13
# File 'documented/util/deep_freeze.rb', line 11

def self.deep_freeze(value)
  deep_freeze_value(value, {}.compare_by_identity)
end

.install_gem_requirements(gems_to_install, user_install: false) ⇒ Object

Installs and optionally requires a set of Ruby gems specified in a Hash.

This method will attempt to install any gems that are not already installed. If a gem is installed and its value is true, it will be required. If installation fails for any gem, an error will be raised listing all failed gems.

Examples:

install_gem_requirements({ "json" => true, "colorize" => false })

Parameters:

  • gems_to_install (Hash{String => Boolean})

    A hash where each key is the name of a gem to install (as a String), and each value is a Boolean indicating whether to require the gem after installation.

Raises:

  • (ArgumentError)

    If the argument is not a Hash, or if the hash contains keys that are not Strings or values that are not TrueClass/FalseClass.

  • (RuntimeError)

    If any gems fail to install, raises an error listing the failed gems.



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'documented/util/util.rb', line 331

def self.install_gem_requirements(gems_to_install, user_install: false)
  raise ArgumentError, "install_gem_requirements must be passed a Hash" unless gems_to_install.is_a?(Hash)
  require "rubygems"
  require "rubygems/dependency_installer"
  installer = Gem::DependencyInstaller.new({ :user_install => user_install, :document => nil })
  installed_gems = Gem::Specification.map { |gem| gem.name }.sort.uniq
  failed_gems = []

  gems_to_install.each do |gem_name, should_require|
    unless gem_name.is_a?(String) && (should_require.is_a?(TrueClass) || should_require.is_a?(FalseClass))
      raise ArgumentError, "install_gem_requirements must be passed a Hash with String key and TrueClass/FalseClass as value"
    end
    begin
      unless installed_gems.include?(gem_name)
        respond("--- Lich: Installing missing ruby gem '#{gem_name}' now, please wait!") if defined?(Script)
        Lich.log("--- Lich: Installing missing ruby gem '#{gem_name}' now, please wait!")
        result = installer.install(gem_name)
        Gem.clear_paths
        Gem::Specification.reset
        Gem::Specification.find_by_name(gem_name).activate
        Lich.log("RubyGem Installer Result: #{result.inspect}")
        unless Gem::Specification.map { |gem| gem.name }.sort.uniq.include?(gem_name)
          Lich.log("RubyGems failed, attempting system method instead!")
          result = system(File.join(RbConfig::CONFIG['bindir'], 'gem'), 'install', gem_name)
          Lich.log("SYSTEM Call Result: #{result.inspect}")
          Gem.clear_paths
          Gem::Specification.reset
          Gem::Specification.find_by_name(gem_name).activate
        end
        respond("--- Lich: Done installing '#{gem_name}' gem!") if defined?(Script)
        Lich.log("--- Lich: Done installing '#{gem_name}' gem!")
      end
      require gem_name if should_require
    rescue LoadError, StandardError
      respond("--- Lich: error: Failed to install/require Ruby gem: #{gem_name}") if defined?(Script)
      respond("--- Lich: error: #{$!}") if defined?(Script)
      Lich.log("installed_gems.include?(#{gem_name}): #{installed_gems.include?(gem_name)} - #{installed_gems.find_all { |gem| gem == gem_name }.inspect}")
      Lich.log("error: Failed to install/require Ruby gem: #{gem_name}")
      Lich.log("error: #{$!}")
      failed_gems.push(gem_name)
    end
  end
  unless failed_gems.empty?
    if defined?(Script.current.name) && Script.current.name != "unknown"
      raise("Please install the failed gems: #{failed_gems.join(', ')} manually to run #{$lich_char}#{Script.current.name}")
    else
      raise("Please install the failed gems: #{failed_gems.join(', ')} manually to continue.")
    end
  end
end

.issue_command(command, start_pattern, end_pattern = /<prompt/, include_end: true, timeout: 5, silent: nil, usexml: true, quiet: false, use_fput: true) ⇒ Array<String>

Issues a command to the game and captures output between start and end patterns.

Parameters:

  • command (String)

    The command to send.

  • start_pattern (Regexp)

    Pattern marking the start of output capture.

  • end_pattern (Regexp, Symbol) (defaults to: /<prompt/)

    Pattern marking the end of output capture. Defaults to /<prompt/. Use :ignore for single-line capture.

  • include_end (Boolean) (defaults to: true)

    Whether to include the end line in the result. Defaults to true.

  • timeout (Integer) (defaults to: 5)

    Timeout in seconds for the command. Defaults to 5.

  • silent (Boolean, nil) (defaults to: nil)

    Whether to silence script output. Defaults to nil (no change).

  • usexml (Boolean) (defaults to: true)

    Whether to use XML downstream. Defaults to true.

  • quiet (Boolean) (defaults to: false)

    If true, suppresses output of lines to FE starting with the start_pattern and ending with the end_pattern. Defaults to false.

  • use_fput (Boolean) (defaults to: true)

    If true, uses fput to send the command; otherwise uses put. Defaults to true.

Returns:

  • (Array<String>)

    Lines of output captured between start and end patterns.



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'documented/util/util.rb', line 153

def self.issue_command(command, start_pattern, end_pattern = /<prompt/, include_end: true, timeout: 5, silent: nil, usexml: true, quiet: false, use_fput: true)
  result = []
  name = self.anon_hook
  filter = false
  ignore_end = end_pattern.eql?(:ignore)

  save_script_silent = Script.current.silent
  save_want_downstream = Script.current.want_downstream
  save_want_downstream_xml = Script.current.want_downstream_xml

  Script.current.silent = silent if !silent.nil?
  Script.current.want_downstream = !usexml
  Script.current.want_downstream_xml = usexml

  begin
    Timeout::timeout(timeout, Interrupt) {
      DownstreamHook.add(name, proc { |line|
        if filter
          if ignore_end || line =~ end_pattern
            DownstreamHook.remove(name)
            filter = false
            if quiet && !ignore_end
              next(preserve_quiet_state_tags(line))
            else
              line
            end
          else
            if quiet
              next(preserve_quiet_state_tags(line))
            else
              line
            end
          end
        elsif line =~ start_pattern
          filter = true
          if quiet
            next(preserve_quiet_state_tags(line))
          else
            line
          end
        else
          line
        end
      }, persist: false) # scoped to this command; removed in the ensure below
      use_fput ? fput(command) : put(command)

      until (line = get) =~ start_pattern; end
      result << line.rstrip
      unless ignore_end
        until (line = get) =~ end_pattern
          result << line.rstrip
        end
      end
      unless ignore_end
        if include_end
          result << line.rstrip
        end
      end
    }
  rescue Interrupt
    nil
  ensure
    DownstreamHook.remove(name)
    Script.current.silent = save_script_silent if !silent.nil?
    Script.current.want_downstream = save_want_downstream
    Script.current.want_downstream_xml = save_want_downstream_xml
  end
  return result
end

.normalize_lookup(effect, val) ⇒ Boolean

Normalizes and performs a lookup for an effect based on the provided value.

Depending on the type of val, this method will:

  • For String: Check if the normalized string matches any key in the effect's hash (case-insensitive, underscores replaced with spaces).
  • For Integer: Check if the effect is active for the given integer value.
  • For Symbol: Check if the normalized symbol matches any key in the effect's hash (case-insensitive, underscores replaced with spaces).

Parameters:

  • effect (String)

    The name of the effect class (without the "Effects::" prefix).

  • val (String, Integer, Symbol)

    The value to look up; can be a string, integer, or symbol.

Returns:

  • (Boolean)

    True if the lookup is successful, false otherwise.

Raises:

  • (RuntimeError)

    If val is not a String, Integer, or Symbol.



23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'documented/util/util.rb', line 23

def self.normalize_lookup(effect, val)
  caller_type = "Effects::#{effect}"
  case val
  when String
    (eval caller_type).to_h.transform_keys(&:to_s).transform_keys(&:downcase).include?(val.downcase.gsub('_', ' '))
  when Integer
    #      seek = mappings.fetch(val, nil)
    (eval caller_type).active?(val)
  when Symbol
    (eval caller_type).to_h.transform_keys(&:to_s).transform_keys(&:downcase).include?(val.to_s.downcase.gsub('_', ' '))
  else
    fail "invalid lookup case #{val.class.name}"
  end
end

.normalize_name(name) ⇒ String

Normalizes a given name by converting it to a lowercase string and replacing or removing certain characters.

The normalization process handles the following cases:

  • Converts spaces and hyphens to underscores.
  • Removes colons and apostrophes.
  • Converts symbols to strings.
  • Converts all characters to lowercase.

Examples:

normalize_name("vault_kick")      #=> "vault_kick"
normalize_name("vault kick")      #=> "vault_kick"
normalize_name("vault-kick")      #=> "vault_kick"
normalize_name(:vault_kick)       #=> "vault_kick"
normalize_name(:vaultkick)        #=> "vaultkick"
normalize_name("predator's eye")  #=> "predators_eye"

Parameters:

  • name (String, Symbol)

    The name to normalize.

Returns:

  • (String)

    The normalized name.



56
57
58
59
60
61
62
63
# File 'documented/util/util.rb', line 56

def self.normalize_name(name)
  normal_name = name.to_s.downcase
  normal_name.gsub!(' ', '_') if name =~ (/\s/)
  normal_name.gsub!('-', '_') if name =~ (/-/)
  normal_name.gsub!(":", '') if name =~ (/:/)
  normal_name.gsub!("'", '') if name =~ (/'/)
  normal_name
end

.preserve_quiet_state_tags(chunk) ⇒ String?

Pulls any QUIET_STATE_TAGS matches out of a chunk that quiet: true is about to drop, so callers can forward just the tags instead of the whole chunk.

The returned string is newline-terminated even though the matched tags themselves never include one. Every other chunk that reaches this pipeline is newline/CRLF-terminated as part of the game server's line-oriented stream framing; a bare, unterminated tag is not a shape of line this pipeline produced before this method existed. For sentinel-supporting frontends (currently only Saga -- see Frontend::ORIGIN_SENTINEL and Game#prefix_origin_sentinel), every forwarded line gets a leading origin-marker byte that the client is expected to consume as routing metadata and strip before display. That worked here once the segment was given the same line termination as everything else the client already handles; without it, the client had nothing to delimit an otherwise content-only segment, and the marker byte fell through to the display as a literal, visible character.

Parameters:

  • chunk (String)

    the raw stream chunk being suppressed.

Returns:

  • (String, nil)

    the concatenated tag matches, in the order they appeared, terminated with a newline; or nil if none were found (signals DownstreamHook to drop the chunk entirely, same as before this method existed).



134
135
136
137
138
139
# File 'documented/util/util.rb', line 134

def self.preserve_quiet_state_tags(chunk)
  tags = chunk.to_s.scan(QUIET_STATE_TAG_PATTERN)
  return nil if tags.empty?

  "#{tags.join}\n"
end

.quiet_command(command, start_pattern, end_pattern, include_end = true, timeout = 5, silent = true) ⇒ Array<String>

Issues a command to the game with XML downstream disabled and captures output between start and end patterns, suppressing all intermediate lines from the frontend.

Examples:

lines = Util.quiet_command('exp', /^\s+Exp\:/, /\<prompt/, include_end: false, timeout: 5, silent: true)

Parameters:

  • command (String)

    the command to send to the game

  • start_pattern (Regexp)

    pattern marking the start of output capture

  • end_pattern (Regexp)

    pattern marking the end of output capture

  • include_end (Boolean) (defaults to: true)

    whether to include the line matching end_pattern in the result; defaults to true

  • timeout (Integer) (defaults to: 5)

    timeout in seconds for the command; defaults to 5

  • silent (Boolean) (defaults to: true)

    whether to silence script output; defaults to true

Returns:

  • (Array<String>)

    lines of output captured between start and end patterns, stripped of trailing whitespace

See Also:



253
254
255
# File 'documented/util/util.rb', line 253

def self.quiet_command(command, start_pattern, end_pattern, include_end = true, timeout = 5, silent = true)
  return issue_command(command, start_pattern, end_pattern, include_end: include_end, timeout: timeout, silent: silent, usexml: false, quiet: true)
end

.quiet_command_xml(command, start_pattern, end_pattern = /<prompt/, include_end = true, timeout = 5, silent = true) ⇒ Array<String>

Issues a command to the game with XML downstream enabled and captures output between start and end patterns, suppressing all intermediate lines from the frontend.

Examples:

lines = Util.quiet_command_xml('score', /^\s+HP\:/, /\<prompt/, include_end: true, timeout: 5, silent: true)

Parameters:

  • command (String)

    the command to send to the game

  • start_pattern (Regexp)

    pattern marking the start of output capture

  • end_pattern (Regexp) (defaults to: /<prompt/)

    pattern marking the end of output capture; defaults to /<prompt/

  • include_end (Boolean) (defaults to: true)

    whether to include the line matching end_pattern in the result; defaults to true

  • timeout (Integer) (defaults to: 5)

    timeout in seconds for the command; defaults to 5

  • silent (Boolean) (defaults to: true)

    whether to silence script output; defaults to true

Returns:

  • (Array<String>)

    lines of output captured between start and end patterns, stripped of trailing whitespace

See Also:



236
237
238
# File 'documented/util/util.rb', line 236

def self.quiet_command_xml(command, start_pattern, end_pattern = /<prompt/, include_end = true, timeout = 5, silent = true)
  return issue_command(command, start_pattern, end_pattern, include_end: include_end, timeout: timeout, silent: silent, usexml: true, quiet: true)
end

.silver_count(timeout = 3) ⇒ Integer

Retrieves the current silver count from the game output by issuing the 'info' command and parsing the response. Uses a downstream hook to filter and extract the silver value.

This method temporarily silences output, sets up a downstream hook to capture the relevant lines, and restores the previous silence state after completion.

Examples:

silver = Util.silver_count

Parameters:

  • timeout (Integer) (defaults to: 3)

    the maximum number of seconds to wait for a response (default: 3)

Returns:

  • (Integer)

    the amount of silver, or 0 if not found or on timeout



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'documented/util/util.rb', line 268

def self.silver_count(timeout = 3)
  silence_me unless (undo_silence = silence_me)
  result = ''
  name = self.anon_hook
  filter = false

  start_pattern = /^\s*Name\:/
  end_pattern = /^\s*Mana\:\s+\-?[0-9]+\s+Silver\:\s+([0-9,]+)/
  ttl = Time.now + timeout
  begin
    # main thread
    DownstreamHook.add(name, proc { |line|
      if filter
        if line =~ end_pattern
          result = $1.dup
          DownstreamHook.remove(name)
          filter = false
        else
          next(nil)
        end
      elsif line =~ start_pattern
        filter = true
        next(nil)
      else
        line
      end
    }, persist: false) # scoped to this command; removed in the ensure below
    # script thread
    fput 'info'
    loop {
      # non-blocking check, this allows us to
      # check the time even when the buffer is empty
      line = get?
      break if line && line =~ end_pattern
      break if Time.now > ttl
      sleep(0.01) # prevent a tight-loop
    }
  ensure
    DownstreamHook.remove(name)
    silence_me if undo_silence
  end
  return result.gsub(',', '').to_i
end