Class: Lich::GameBase::Game

Inherits:
Object
  • Object
show all
Defined in:
documented/games.rb

Overview

Base Game class with common functionality

Direct Known Subclasses

DragonRealms::Game, Lich::Gemstone::Game

Defined Under Namespace

Classes: ServerQueueOverflow

Constant Summary collapse

READ_TIMEOUT_SECONDS =

Seconds to wait for readable game socket data before one read timeout.

100
MAX_CONSECUTIVE_READ_TIMEOUTS =

Consecutive read timeouts allowed before treating the game link as dead.

3
READ_TIMEOUT =

Sentinel returned when the game socket has no readable data before timeout.

Object.new.freeze
SERVER_QUEUE_CAPACITY =

A full queue means the parser cannot preserve the game stream. Dropping records or blocking the socket reader would both make recovery unsafe.

4_096

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

._bufferObject (readonly)

Returns the value of attribute _buffer.



447
448
449
# File 'documented/games.rb', line 447

def _buffer
  @_buffer
end

.bufferObject (readonly)

Returns the value of attribute buffer.



447
448
449
# File 'documented/games.rb', line 447

def buffer
  @buffer
end

.game_instanceObject (readonly)

Returns the value of attribute game_instance.



447
448
449
# File 'documented/games.rb', line 447

def game_instance
  @game_instance
end

.reader_threadObject (readonly)

Returns the value of attribute reader_thread.



447
448
449
# File 'documented/games.rb', line 447

def reader_thread
  @reader_thread
end

.server_queueObject (readonly)

Returns the value of attribute server_queue.



447
448
449
# File 'documented/games.rb', line 447

def server_queue
  @server_queue
end

.threadObject (readonly)

Returns the value of attribute thread.



447
448
449
# File 'documented/games.rb', line 447

def thread
  @thread
end

Class Method Details

._getsString?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Retrieves the next line from the testing/debug buffer.

Returns:

  • (String, nil)

    the next buffered line from _buffer, or nil when empty



794
795
796
# File 'documented/games.rb', line 794

def _gets
  @_buffer.gets
end

._puts(str) ⇒ true?

Writes a string to the game server socket.

Thread-safe via mutex. Silently absorbs fatal connection errors so callers (scripts) are not killed by a broken server link.

Parameters:

  • str (String)

    the raw command to send upstream

Returns:

  • (true, nil)

    true when written; nil on connection error



747
748
749
750
751
752
753
754
755
# File 'documented/games.rb', line 747

def _puts(str)
  @mutex.synchronize do
    @socket.puts(str)
  end
  true
rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNABORTED, IOError => e
  Lich.log "error: _puts: #{e}\n\t#{e.backtrace.first}"
  nil
end

.autostarted?Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns whether the autostart sequence has completed.

Returns:

  • (Boolean)

    true if autostart scripts have been launched



453
454
455
# File 'documented/games.rb', line 453

def autostarted?
  @@autostarted
end

.check_stream_desync!(parse_errors) ⇒ Object

Promote truncation-class Ox parse errors to GameStreamDesyncError so a desynced stream still hits the log + reset recovery path instead of being silently absorbed (see the GameStreamDesyncError comment). parse_errors is XMLData's collected Ox error-callback messages for the fragment just parsed.



1260
1261
1262
1263
1264
1265
# File 'documented/games.rb', line 1260

def check_stream_desync!(parse_errors)
  desync = parse_errors.find do |message|
    STREAM_DESYNC_ERRORS.any? { |pattern| pattern.match?(message) }
  end
  raise GameStreamDesyncError, desync if desync
end

.closevoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Closes the game socket and kills the reader and parser threads.



732
733
734
735
736
737
738
# File 'documented/games.rb', line 732

def close
  if @socket
    @socket.close rescue nil
    @reader_thread.kill rescue nil
    @thread.kill rescue nil
  end
end

.closed?Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns whether the game socket is closed or nil.

Returns:

  • (Boolean)

    true if the socket is nil or closed



628
629
630
# File 'documented/games.rb', line 628

def closed?
  @socket.nil? || @socket.closed?
end

.connection_disruption_log_message(error) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Formats a connection disruption error for logging.

Parameters:

  • error (StandardError)

    the error to format

Returns:

  • (String)

    a log message with error class and first line of message



1517
1518
1519
1520
1521
# File 'documented/games.rb', line 1517

def connection_disruption_log_message(error)
  return "GameStreamDesyncError: #{error.message.lines.first&.strip}" if error.is_a?(GameStreamDesyncError)

  "#{error.class}: #{error.message}"
end

.display_ruby_warningvoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Displays a formatted terminal table warning if the current Ruby version is below the recommended minimum.

Called from handle_autostart if a RECOMMENDED_RUBY version is defined and the running version is older.



1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
# File 'documented/games.rb', line 1161

def display_ruby_warning
  ruby_warning = Terminal::Table.new
  ruby_warning.title = "Ruby Recommended Version Warning"
  ruby_warning.add_row(["Please update your Ruby installation."])
  ruby_warning.add_row(["You're currently running Ruby v#{Gem::Version.new(RUBY_VERSION)}!"])
  ruby_warning.add_row(["It's recommended to run Ruby v#{Gem::Version.new(RECOMMENDED_RUBY)} or higher!"])
  ruby_warning.add_row(["Future Lich5 releases will soon require this newer version."])
  ruby_warning.add_row([" "])
  ruby_warning.add_row(["Visit the following link for info on updating:"])

  # Use instance to get the appropriate documentation URL
  if @game_instance
    ruby_warning.add_row([@game_instance.get_documentation_url])
  else
    ruby_warning.add_row(["Unknown game type detected."])
    ruby_warning.add_row(["Unsure of proper documentation, please seek assistance via discord!"])
  end

  ruby_warning.to_s.split("\n").each do |row|
    Lich::Messaging.mono(Lich::Messaging.monsterbold(row))
  end
end

.enqueue_server_string(server_string, enqueued_monotonic_at) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Adds a server string to the processing queue with a monotonic timestamp.

Parameters:

  • server_string (String)

    the server string to queue

  • enqueued_monotonic_at (Numeric)

    the monotonic time when enqueued

Raises:



831
832
833
834
835
836
# File 'documented/games.rb', line 831

def enqueue_server_string(server_string, enqueued_monotonic_at)
  @server_queue.push([server_string, enqueued_monotonic_at], true)
  record_server_queue_enqueue
rescue ThreadError
  raise ServerQueueOverflow, "game parser queue exceeded #{SERVER_QUEUE_CAPACITY} records"
end

.fix_invalid_settings_info(server_string) ⇒ Object

The server sends a malformed <settingsInfo ... space not found .../> (an attribute with no '=') to characters that have never connected with the Wrayth/StormFront client. REXML raised on it (the rescue repaired it); Ox tolerates it and emits "no attribute value", so it is repaired from repair_malformed_attributes_and_reparse. @@settings_init_needed makes gameloader's PostLoad seed a valid client record (see settings_init_needed? and lib/common/gameloader.rb).



1297
1298
1299
1300
1301
1302
1303
1304
# File 'documented/games.rb', line 1297

def fix_invalid_settings_info(server_string)
  return unless server_string =~ /<settingsInfo .*?space not found /

  Lich.log "Invalid settingsInfo XML tags detected: #{server_string.inspect}"
  server_string.sub!(/\s\bspace not found\b\s/, " client='1.0.1.28' ")
  Lich.log "Invalid settingsInfo XML tags fixed to: #{server_string.inspect}"
  @@settings_init_needed = true
end

.getsString?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Retrieves the next line from the main game buffer.

Returns:

  • (String, nil)

    the next buffered server line, or nil when buffer is empty



786
787
788
# File 'documented/games.rb', line 786

def gets
  @buffer.gets
end

.handle_autostartvoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Executes the autostart sequence: checks for version updates, syncs script repos, and launches the autostart script.

Runs once when the first tag is received. Version check and repo sync run in background threads to avoid blocking XML parsing. Sets @@autostarted to true when complete.



1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
# File 'documented/games.rb', line 1119

def handle_autostart
  if defined?(LICH_VERSION) && defined?(Lich.core_updated_with_lich_version) &&
     Gem::Version.new(LICH_VERSION) > Gem::Version.new(Lich.core_updated_with_lich_version)
    Lich::Messaging.mono(Lich::Messaging.monsterbold("New installation or updated version of Lich5 detected!"))
    Lich::Messaging.mono(Lich::Messaging.monsterbold("Installing newest core scripts available to ensure you're up-to-date!"))
    Lich::Messaging.mono("")
    Lich::Util::Update.update_core_data_and_scripts
  end

  # Sync script repositories on login for both DR and GS.
  # MUST run in a background thread -- sync_all_repos makes HTTP calls
  # that block the game thread, preventing process_xml_data from setting
  # XMLData.name. If Vars is accessed before XMLData.name is set, it
  # loads/saves under scope "DR:" instead of "DR:CharName", overwriting
  # real data with an empty session.
  Thread.new do
    # Wait for XMLData.name to be populated by process_xml_data
    # before touching Vars. 200 x 50ms = 10s max wait.
    200.times do
      break if !XMLData.name.nil? && !XMLData.name.empty?

      sleep 0.05
    end
    Lich::Util::Update.sync_all_repos if !XMLData.name.nil? && !XMLData.name.empty?
  rescue StandardError => e
    Lich.log "repo_sync(login): #{e.class}: #{e.message}"
  end

  Script.start('autostart') if defined?(Script) && Script.respond_to?(:exists?) && Script.exists?('autostart')
  @@autostarted = true

  display_ruby_warning if defined?(RECOMMENDED_RUBY) && Gem::Version.new(RUBY_VERSION) < Gem::Version.new(RECOMMENDED_RUBY)
end

.handle_thread_error(error) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Evaluates a thread error to determine whether the server thread should retry.

Logs recognized connection disruptions (timeouts, resets) at info level; logs other errors at error level with backtrace. Timeouts after max retries, connection errors, stream desync, and queue overflow are fatal (no retry). Unknown errors retry if the socket/client are still alive.

Parameters:

  • error (StandardError)

    the error to evaluate

Returns:

  • (Boolean)

    true if a retry is safe, false if fatal



1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
# File 'documented/games.rb', line 1392

def handle_thread_error(error)
  if recognized_connection_disruption?(error)
    shutdown_log.info("server_thread: #{connection_disruption_log_message(error)}")
    shutdown_log.debug("server_thread backtrace: #{error.backtrace.join("\n\t")}") if error.backtrace
  else
    shutdown_log.error("server_thread: #{error}\n\t#{Array(error.backtrace).join("\n\t")}")
  end
  sleep 0.2

  case error
  when Errno::ETIMEDOUT, Errno::EWOULDBLOCK, IO::TimeoutError
    # Timeout errors reach this outer handler only after the inner
    # reader loop has exhausted its consecutive-timeout threshold.
    shutdown_log.info("game timeout - will not retry")
    return false
  when Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED
    # Connection errors are fatal
    shutdown_log.info("connection error - will not retry")
    return false
  when GameStreamDesyncError
    shutdown_log.info("game stream desync detected - will not retry")
    return false
  when ServerQueueOverflow
    shutdown_log.info("game parser queue overflow - will not retry")
    return false
  else
    # Check if socket/client are closed or if it's a known fatal error
    if !$_CLIENT_.alive? || @socket.closed?
      shutdown_log.info("client or socket closed - will not retry")
      return false
    elsif error.to_s =~ /invalid argument|A connection attempt failed|An existing connection was forcibly closed|An established connection was aborted by the software in your host machine./i
      shutdown_log.info("fatal error pattern detected - will not retry")
      return false
    else
      shutdown_log.debug("unknown server thread error - will attempt retry")
      return true
    end
  end
end

.initialize_buffersvoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Initializes or resets all socket, queue, and buffer state.

Called at startup and after reconnects to clear stale data.



483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
# File 'documented/games.rb', line 483

def initialize_buffers
  @socket = nil
  @mutex = Mutex.new
  @last_recv = nil
  @thread = nil
  @reader_thread = nil
  @remote_eof = false
  @server_queue = SizedQueue.new(SERVER_QUEUE_CAPACITY)
  reset_server_queue_stats!
  @buffer = Lich::Common::SharedBuffer.new
  @_buffer = Lich::Common::SharedBuffer.new
  @_buffer.max_size = 1000
  @@autostarted = false
  @@settings_init_needed = false
  @cli_scripts = false
  @room_number_after_ready = false
  @last_id_shown_room_window = 0
  @game_instance = nil
  # strip_xml's multiline carry is a process-global; clear it here so a
  # fragment left open before a reconnect/session reset does not bleed
  # into the next session.
  $strip_xml_multiline = {}
end

.intentional_shutdown_close_error?(error) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Detects whether an error is a normal consequence of orderly user shutdown.

Returns true if the error is a stream-closed error (EBADF, "stream closed", etc.) and ShutdownCoordinator reports an orderly exit is in progress and the socket is closed.

Parameters:

  • error (StandardError)

    the error to evaluate

Returns:

  • (Boolean)

    true if this is a normal shutdown error



1488
1489
1490
1491
1492
1493
1494
1495
# File 'documented/games.rb', line 1488

def intentional_shutdown_close_error?(error)
  return false unless defined?(Lich::Common::ShutdownCoordinator)
  return false unless Lich::Common::ShutdownCoordinator.orderly_user_exit?
  return false unless @socket&.closed?

  error.is_a?(Errno::EBADF) ||
    error.to_s =~ /stream closed in another thread|closed stream|bad file descriptor/i
end

.log_error(message, error) ⇒ Object (protected)



1525
1526
1527
# File 'documented/games.rb', line 1525

def log_error(message, error)
  Lich.log "#{message}: #{error}\n\t#{error.backtrace.join("\n\t")}"
end

.open(host, port) ⇒ TCPSocket

Note:

Connection errors propagate to the caller. Use open_with_timeout to bound how long the connect may block.

Opens the TCP connection to the game server and starts the socket's wrap and main reader threads.

Parameters:

  • host (String)

    game server hostname

  • port (Integer)

    game server port

Returns:

  • (TCPSocket)

    the connected, configured game socket

See Also:



525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
# File 'documented/games.rb', line 525

def open(host, port)
  @remote_eof = false
  @socket = TCPSocket.open(host, port)

  # Configure socket with error handling
  # More forgiving settings for Windows reliability under network stress
  begin
    SocketConfigurator.configure(@socket,
                                 keepalive: {
                                   enable: true,
                                   idle: 30,       # 30s idle before first keepalive; defensive against L3/L4 idle reapers (best-effort, see SocketConfigurator)
                                   interval: 30    # 30 seconds between keepalive probes
                                 },
                                 linger: {
                                   enable: true,
                                   timeout: 5      # Wait 5 seconds for data to send on close
                                 },
                                 timeout: {
                                   recv: 30,       # 30 second receive timeout (increased from 10)
                                   send: 30        # 30 second send timeout (increased from 10)
                                 },
                                 buffer_size: {
                                   recv: 32768,    # 32KB receive buffer (reduced from 65536)
                                   send: 32768     # 32KB send buffer (reduced from 65536)
                                 },
                                 tcp_nodelay: true, # Disable Nagle's algorithm for low latency
                                 tcp_maxrt: 10)     # Windows: max 10 retransmissions before giving up

    Lich.log("Socket configured successfully for #{host}:#{port}") if ARGV.include?("--debug")
  rescue StandardError => e
    # Log the error but continue - socket may still work with default settings
    log_error("Socket configuration error (continuing with defaults)", e)
    Lich.log("WARNING: Socket running with default OS settings - may be less reliable under network stress")
  end

  @socket.sync = true

  start_wrap_thread
  start_main_thread

  @socket
end

.open_with_timeout(host, port, timeout = 30) ⇒ void

This method returns an undefined value.

Connects to the game server on a background thread, enforcing a connect timeout that a bare open cannot. Surfaces a stuck or failed connect instead of letting startup proceed with a dead socket.

Parameters:

  • host (String)

    game server hostname

  • port (Integer)

    game server port

  • timeout (Integer, Float) (defaults to: 30)

    seconds to wait for the connect to complete

Raises:

  • (RuntimeError)

    if the connect does not complete within timeout

  • (StandardError)

    re-raises whatever open raises (e.g. Errno::ECONNREFUSED) so the caller's rescue runs

See Also:



580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
# File 'documented/games.rb', line 580

def open_with_timeout(host, port, timeout = 30)
  connect_thread = Thread.new {
    # report_on_exception off: a failed open is surfaced by the join below
    # (which re-raises it), not by an auto-printed thread warning.
    Thread.current.report_on_exception = false
    self.open(host, port)
  }
  # join returns nil on timeout, the thread on success, and re-raises the
  # thread's exception on failure -- so a Game.open that errors (e.g.
  # connection refused) propagates to the caller's rescue instead of being
  # silently swallowed (the old `if connect_thread.status` could not tell a
  # thread that died with an exception, status nil, from a normal finish,
  # status false).
  if connect_thread.join(timeout).nil?
    connect_thread.kill rescue nil
    raise "error: timed out connecting to #{host}:#{port}"
  end
end

.prefix_origin_sentinel(string) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Prefixes each line of a string with the frontend origin sentinel.

Parameters:

  • string (String)

    the input string, possibly multiline

Returns:

  • (String)

    the string with each line prefixed with Frontend::ORIGIN_SENTINEL



462
463
464
# File 'documented/games.rb', line 462

def prefix_origin_sentinel(string)
  string.gsub(/^.+$/) { |line| "#{Frontend::ORIGIN_SENTINEL}#{line}" }
end

.process_downstream_hooks(server_string) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Runs downstream hooks and sends the modified server string to connected clients.

Processes room information, applies frontend-specific conversions (genie/frostbite room number formatting, GSL translation), calls game-specific room display methods, and sends to all connected detachable clients or the main client.

Parameters:

  • server_string (String)

    the server string to process and transmit



1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
# File 'documented/games.rb', line 1315

def process_downstream_hooks(server_string)
  if (alt_string = DownstreamHook.run(server_string))
    process_room_information(alt_string)

    # Handle frontend-specific modifications
    if Frontend.client.eql?('genie') && alt_string =~ /^<streamWindow id='room' title='Room' subtitle=" - \[.*\] \((?:\d+|\*\*)\)"/
      alt_string.sub!(/] \((?:\d+|\*\*)\)/) { "]" }
    end

    if Frontend.client.eql?('frostbite') && alt_string =~ /^<streamWindow id='main' title='Story' subtitle=" - \[.*\] \((?:\d+|\*\*)\)"/
      alt_string.sub!(/] \((?:\d+|\*\*)\)/) { "]" }
    end

    # Handle room number display
    if @room_number_after_ready && alt_string =~ /<prompt /
      alt_string = @game_instance ? @game_instance.process_room_display(alt_string) : alt_string
      @room_number_after_ready = false
    end

    # Handle frontend-specific conversions
    if Frontend.supports_gsl?
      alt_string = sf_to_wiz(alt_string)
    end
    # Handle prefix origin sentinel if FE supports it
    alt_string = prefix_origin_sentinel(alt_string) if Frontend.supports_sentinel?

    # Send to client
    send_to_client(alt_string)
  end
end

.process_room_information(alt_string) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Detects and processes room-name style tags to trigger room display modifications.

Sets @room_number_after_ready to true when a roomName style tag is detected, signaling that the next prompt should trigger game-instance room display processing.

Parameters:

  • alt_string (String)

    the server string to examine



1354
1355
1356
1357
1358
1359
1360
1361
1362
# File 'documented/games.rb', line 1354

def process_room_information(alt_string)
  if alt_string =~ /^(<pushStream id="familiar" ifClosedStyle="watching"\/>)?(?:<resource picture="\d+"\/>|<popBold\/>)?<style id="roomName"\s+\/>/
    if (Lich.display_lichid == true || Lich.display_uid == true || Lich.hide_uid_flag == true)
      @game_instance ? @game_instance.modify_room_display(alt_string) : alt_string
    end
    @room_number_after_ready = true
    alt_string
  end
end

.process_server_string(server_string) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Main entry point for processing a server string: validates game state, cleans the string, parses XML, triggers downstream hooks, and manages autostart.

Parameters:

  • server_string (String)

    the raw server string to process



1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
# File 'documented/games.rb', line 1067

def process_server_string(server_string)
  $cmd_prefix = String.new if server_string =~ /^\034GSw/

  # Load game-specific modules if needed
  unless (XMLData.game.nil? || XMLData.game.empty?)
    unless Module.const_defined?(:GameLoader)
      require_relative 'common/gameloader'
      GameLoader.load!
    end
  end

  # Set instance if not already set
  if @game_instance.nil? && !XMLData.game.nil? && !XMLData.game.empty?
    set_game_instance(XMLData.game)
  end

  # Clean server string based on game type
  if @game_instance
    server_string = @game_instance.clean_serverstring(server_string)
    return if server_string.nil? # Buffering split component, wait for next line
  end

  # Debug output if needed
  pp server_string if defined?($deep_debug) && $deep_debug

  # Push to server buffer
  $_SERVERBUFFER_.push(server_string)

  # Handle autostart
  handle_autostart if !@@autostarted && server_string =~ /<app char/

  # Handle CLI scripts
  if !@cli_scripts && @@autostarted && !XMLData.name.nil? && !XMLData.name.empty?
    start_cli_scripts
  end

  # Process XML data
  process_xml_data(server_string) unless server_string =~ /^<settings /

  # Run downstream hooks
  process_downstream_hooks(server_string)
end

.process_xml_data(server_string) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Parses the server string as XML using Ox in SAX mode and updates XMLData.

Handles stream desync detection (truncated fragments), repairs malformed attributes (nested quotes, settingsInfo bugs), and splits the parsed output into lines for downstream processing. Calls game-specific processing and fires Script hooks.

Parameters:

  • server_string (String)

    the raw or partially pre-cleaned server string



1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
# File 'documented/games.rb', line 1210

def process_xml_data(server_string)
  begin
    # Ox is a permissive parser: it handles Simu's not-quite-XML stream
    # without the clean/retry dance REXML required (nested quotes, missing
    # 'd' end tags, etc. are tolerated rather than raised). XMLData itself
    # implements the Ox::Sax interface, so Ox parses straight into it. No
    # <root> wrapper needed: that was a REXML requirement (single root); Ox
    # handles multiple top-level elements and bare text directly.
    XMLData.sax_parse_errors.clear
    # convert_special: false keeps Ox in bytes-land: it never decodes a
    # numeric entity (e.g. &#8217;) into UTF-8. The five standard XML
    # entities are decoded by XMLData#attr/#text instead. Values are left
    # in Ox's native (ASCII-8BIT) encoding -- REXML effectively produced
    # ASCII for this (high-byte-scrubbed) stream, so retagging to
    # Windows-1252 was a divergence and caused entity corruption.
    Ox.sax_parse(XMLData, server_string, convert_special: false, symbolize: false, skip: :skip_none)
    check_stream_desync!(XMLData.sax_parse_errors)
    repair_malformed_attributes_and_reparse(server_string)
  rescue GameStreamDesyncError => e
    # A truncated/desynced fragment. Ox never raises on malformed stream
    # content -- it reports via the error callback, and check_stream_desync!
    # promotes truncation-class errors to this exception. Log and reset
    # rather than killing the server thread.
    Lich.log "warning: stream desync (#{e.message}); resetting XMLData: #{server_string.inspect}"
    XMLData.reset
    return
  end

  stripped_server = strip_xml(server_string, type: "main")

  # Process game-specific data using instance
  if @game_instance && Module.const_defined?(:GameLoader)
    @game_instance.process_game_specific_data(server_string, stripped_server)
  end

  # Process downstream XML
  Script.new_downstream_xml(server_string) if defined?(Script)

  # Process stripped server string
  stripped_server.split("\r\n").each do |line|
    @buffer.update(line) if defined?(TESTING) && TESTING
    Script.new_downstream(line) if defined?(Script) && !line.empty?
  end
end

.puts(str) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Sends a command to the game server and logs it to the client buffer.

Records the command in $CLIENTBUFFER, echoes to the user unless the script is silent, updates $LASTUPSTREAM, and forwards to the game via _puts.

Parameters:

  • str (String)

    the command to send (without command prefix)



765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
# File 'documented/games.rb', line 765

def puts(str)
  if Script.current&.file_name
    script_name = "#{Script.current.custom? ? 'custom/' : ''}#{Script.current&.name}"
  else
    script_name = Script.current&.name || '(unknown script)'
  end

  $_CLIENTBUFFER_.push "[#{script_name}]#{$SEND_CHARACTER}#{$cmd_prefix}#{str}\r\n"

  unless Script.current&.silent
    respond "[#{script_name}]#{$SEND_CHARACTER}#{str}\r\n"
  end

  _puts "#{$cmd_prefix}#{str}"
  $_LASTUPSTREAM_ = "[#{script_name}]#{$SEND_CHARACTER}#{str}"
end

.read_server_string(read_timeout: READ_TIMEOUT_SECONDS) ⇒ String, ...

Reads one game-server line after an explicit readiness wait.

Ruby does not reliably surface SO_RCVTIMEO through TCPSocket#gets on every supported platform. Waiting with IO.select makes the reader's no-data timeout deterministic while preserving gets-based EOF handling.

Parameters:

  • read_timeout (Numeric) (defaults to: READ_TIMEOUT_SECONDS)

    seconds to wait for game socket data

Returns:

  • (String, nil, Object)

    a server line, nil for EOF, or READ_TIMEOUT



1049
1050
1051
1052
1053
# File 'documented/games.rb', line 1049

def read_server_string(read_timeout: READ_TIMEOUT_SECONDS)
  return READ_TIMEOUT unless IO.select([@socket], nil, nil, read_timeout)

  @socket.gets
end

.recognized_connection_disruption?(error) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Tests whether an error represents a known connection disruption.

Parameters:

  • error (StandardError)

    the error to test

Returns:

  • (Boolean)

    true for timeouts, resets, aborts, or stream desync



1502
1503
1504
1505
1506
1507
1508
1509
1510
# File 'documented/games.rb', line 1502

def recognized_connection_disruption?(error)
  error.is_a?(Errno::ETIMEDOUT) ||
    error.is_a?(Errno::EWOULDBLOCK) ||
    error.is_a?(IO::TimeoutError) ||
    error.is_a?(Errno::ECONNRESET) ||
    error.is_a?(Errno::EPIPE) ||
    error.is_a?(Errno::ECONNABORTED) ||
    error.is_a?(GameStreamDesyncError)
end

.record_server_parser_timing(parse_time) ⇒ nil

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Records performance metrics for XML parsing and processing.

Parameters:

  • parse_time (Numeric)

    seconds spent parsing/processing the server string

Returns:

  • (nil)


889
890
891
892
893
894
# File 'documented/games.rb', line 889

def record_server_parser_timing(parse_time)
  @server_parser_last = parse_time
  @server_parser_total = @server_parser_total.to_f + parse_time.to_f
  @server_parser_max = parse_time if parse_time.to_f > @server_parser_max.to_f
  nil
end

.record_server_queue_dequeue(enqueued_monotonic_at = nil) ⇒ nil

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Records metrics when a server string is removed from the queue.

Increments dequeue count, updates queue depth, and calculates queue wait time if a monotonic timestamp was provided.

Parameters:

  • enqueued_monotonic_at (Numeric, nil) (defaults to: nil)

    the monotonic timestamp when enqueued

Returns:

  • (nil)


868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
# File 'documented/games.rb', line 868

def record_server_queue_dequeue(enqueued_monotonic_at = nil)
  @server_queue_dequeued = @server_queue_dequeued.to_i + 1
  depth = @server_queue&.length.to_i
  @server_queue_last_depth = depth
  @server_queue_last_dequeue_at = Time.now
  if enqueued_monotonic_at
    wait = Process.clock_gettime(Process::CLOCK_MONOTONIC) - enqueued_monotonic_at.to_f
    if wait >= 0.0
      @server_queue_last_wait = wait
      @server_queue_total_wait = @server_queue_total_wait.to_f + wait
      @server_queue_max_wait = wait if wait > @server_queue_max_wait.to_f
    end
  end
  nil
end

.record_server_queue_enqueuenil

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Records metrics when a server string is added to the queue.

Increments enqueue count, updates queue depth and max depth, and records the timestamp.

Returns:

  • (nil)


815
816
817
818
819
820
821
822
# File 'documented/games.rb', line 815

def record_server_queue_enqueue
  @server_queue_enqueued = @server_queue_enqueued.to_i + 1
  depth = @server_queue&.length.to_i
  @server_queue_last_depth = depth
  @server_queue_max_depth = depth if depth > @server_queue_max_depth.to_i
  @server_queue_last_enqueue_at = Time.now
  nil
end

.record_server_reader_timing(hook_time:, enqueue_time:, process_time:) ⇒ nil

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Records performance metrics for socket read and hook processing.

Parameters:

  • hook_time (Numeric)

    seconds spent running SocketReadHook

  • enqueue_time (Numeric)

    seconds spent enqueueing to server_queue

  • process_time (Numeric)

    total seconds for the read/hook/enqueue cycle

Returns:

  • (nil)


845
846
847
848
849
850
851
852
853
854
855
856
857
858
# File 'documented/games.rb', line 845

def record_server_reader_timing(hook_time:, enqueue_time:, process_time:)
  @server_reader_hook_last = hook_time
  @server_reader_hook_total = @server_reader_hook_total.to_f + hook_time.to_f
  @server_reader_hook_max = hook_time if hook_time.to_f > @server_reader_hook_max.to_f

  @server_reader_enqueue_last = enqueue_time
  @server_reader_enqueue_total = @server_reader_enqueue_total.to_f + enqueue_time.to_f
  @server_reader_enqueue_max = enqueue_time if enqueue_time.to_f > @server_reader_enqueue_max.to_f

  @server_reader_process_last = process_time
  @server_reader_process_total = @server_reader_process_total.to_f + process_time.to_f
  @server_reader_process_max = process_time if process_time.to_f > @server_reader_process_max.to_f
  nil
end

.record_shutdown_reason(reason, source:, detail: nil) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Records the shutdown reason with ShutdownCoordinator for orderly exit handling.

Parameters:

  • reason (Symbol)

    the shutdown reason (e.g., :game_timeout)

  • source (Symbol)

    the source thread (e.g., :game_reader, :game_parser)

  • detail (Class, nil) (defaults to: nil)

    optional exception class for context



1464
1465
1466
1467
1468
1469
1470
# File 'documented/games.rb', line 1464

def record_shutdown_reason(reason, source:, detail: nil)
  return unless defined?(Lich::Common::ShutdownCoordinator)

  Lich::Common::ShutdownCoordinator.request(reason: reason, source: source, detail: detail)
rescue StandardError => e
  shutdown_log.warning("failed to record shutdown reason #{reason.inspect}: #{e.class}: #{e.message}")
end

.remote_eof?Boolean

Returns whether the game server closed its side of the socket.

Returns:

  • (Boolean)

    whether the game server closed its side of the socket



633
634
635
# File 'documented/games.rb', line 633

def remote_eof?
  @remote_eof == true
end

.repair_malformed_attributes_and_reparse(server_string) ⇒ Object

Ox reports "no attribute value" for two repairable malformations that scatter a tag into junk attributes: the settingsInfo space-not-found server bug, and a same-quote inside a quoted value (Simu's dynamic dialogs, e.g. title='Tsetem's Items'). Both raised in REXML and were repaired in the rescue; Ox tolerates them, so drive the repair off its error report -- only the rare flagged line pays the cost. Apply the repairs; if any changed the line, drop the junk the first parse committed and parse once more, ignoring any errors on that pass so we never loop. Escaped '/" round-trip back to the literal char via XmlEntities.decode and the front-end's own entity decoding.



1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
# File 'documented/games.rb', line 1277

def repair_malformed_attributes_and_reparse(server_string)
  return unless XMLData.sax_parse_errors.any? { |message| NO_ATTRIBUTE_VALUE_ERROR.match?(message) }

  before = server_string.dup
  fix_invalid_settings_info(server_string)
  XMLCleaner.clean_nested_quotes(server_string)
  return if server_string == before # nothing to repair (e.g. a genuine valueless attribute)

  XMLData.reset
  XMLData.sax_parse_errors.clear
  Ox.sax_parse(XMLData, server_string, convert_special: false, symbolize: false, skip: :skip_none)
end

.reset_server_queue_stats!nil

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Resets all queue and parser performance statistics to initial state.

Called at startup and can be called during operation to clear accumulated metrics.

Returns:

  • (nil)


643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
# File 'documented/games.rb', line 643

def reset_server_queue_stats!
  @server_queue_enqueued = 0
  @server_queue_dequeued = 0
  @server_queue_last_depth = @server_queue&.length.to_i
  @server_queue_max_depth = @server_queue_last_depth
  @server_queue_last_enqueue_at = nil
  @server_queue_last_dequeue_at = nil
  @server_queue_last_wait = nil
  @server_queue_max_wait = 0.0
  @server_queue_total_wait = 0.0
  @server_reader_hook_last = nil
  @server_reader_hook_max = 0.0
  @server_reader_hook_total = 0.0
  @server_reader_enqueue_last = nil
  @server_reader_enqueue_max = 0.0
  @server_reader_enqueue_total = 0.0
  @server_reader_process_last = nil
  @server_reader_process_max = 0.0
  @server_reader_process_total = 0.0
  @server_parser_last = nil
  @server_parser_max = 0.0
  @server_parser_total = 0.0
  nil
end

.send_to_client(alt_string) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Transmits a server string to all connected clients.

Sends to detachable clients if any are registered and alive, otherwise sends to the main $CLIENT connection.

Parameters:

  • alt_string (String)

    the server string to send



1372
1373
1374
1375
1376
1377
1378
1379
1380
# File 'documented/games.rb', line 1372

def send_to_client(alt_string)
  detachable_clients = $_DETACHABLE_CLIENT_REGISTRY_&.snapshot || []
  detachable_clients = [$_DETACHABLE_CLIENT_] if detachable_clients.empty? && $_DETACHABLE_CLIENT_
  if !detachable_clients.empty?
    detachable_clients.each { |client| client.write(alt_string) if client.alive? }
  elsif $_CLIENT_
    $_CLIENT_.write(alt_string)
  end
end

.server_queue_stats(reset: false) ⇒ Hash

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns a snapshot of queue and parser performance metrics.

Parameters:

  • reset (Boolean) (defaults to: false)

    whether to reset statistics after returning them

Returns:

  • (Hash)

    a dict with keys: depth, last_depth, max_depth, enqueued, dequeued, last_wait, max_wait, avg_wait (in seconds), plus corresponding _ms variants in milliseconds, reader hook/enqueue/process stats (last/max/avg milliseconds), parser stats, timestamps (last_enqueue_at, last_dequeue_at), and thread status



676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
# File 'documented/games.rb', line 676

def server_queue_stats(reset: false)
  depth = @server_queue&.length.to_i
  dequeued = @server_queue_dequeued.to_i
  last_wait = @server_queue_last_wait
  max_wait = @server_queue_max_wait.to_f
  avg_wait = dequeued.positive? ? @server_queue_total_wait.to_f / dequeued : 0.0
  enqueued = @server_queue_enqueued.to_i
  hook_last = @server_reader_hook_last
  hook_max = @server_reader_hook_max.to_f
  hook_avg = enqueued.positive? ? @server_reader_hook_total.to_f / enqueued : 0.0
  enqueue_last = @server_reader_enqueue_last
  enqueue_max = @server_reader_enqueue_max.to_f
  enqueue_avg = enqueued.positive? ? @server_reader_enqueue_total.to_f / enqueued : 0.0
  process_last = @server_reader_process_last
  process_max = @server_reader_process_max.to_f
  process_avg = enqueued.positive? ? @server_reader_process_total.to_f / enqueued : 0.0
  parser_last = @server_parser_last
  parser_max = @server_parser_max.to_f
  parser_avg = dequeued.positive? ? @server_parser_total.to_f / dequeued : 0.0
  stats = {
    depth: depth,
    last_depth: @server_queue_last_depth.to_i,
    max_depth: [@server_queue_max_depth.to_i, depth].max,
    enqueued: enqueued,
    dequeued: dequeued,
    last_wait: last_wait,
    max_wait: max_wait,
    avg_wait: avg_wait,
    last_wait_ms: last_wait ? (last_wait * 1000.0).round(3) : nil,
    max_wait_ms: (max_wait * 1000.0).round(3),
    avg_wait_ms: (avg_wait * 1000.0).round(3),
    reader_hook_last_ms: hook_last ? (hook_last * 1000.0).round(3) : nil,
    reader_hook_max_ms: (hook_max * 1000.0).round(3),
    reader_hook_avg_ms: (hook_avg * 1000.0).round(3),
    reader_enqueue_last_ms: enqueue_last ? (enqueue_last * 1000.0).round(3) : nil,
    reader_enqueue_max_ms: (enqueue_max * 1000.0).round(3),
    reader_enqueue_avg_ms: (enqueue_avg * 1000.0).round(3),
    reader_process_last_ms: process_last ? (process_last * 1000.0).round(3) : nil,
    reader_process_max_ms: (process_max * 1000.0).round(3),
    reader_process_avg_ms: (process_avg * 1000.0).round(3),
    parser_process_last_ms: parser_last ? (parser_last * 1000.0).round(3) : nil,
    parser_process_max_ms: (parser_max * 1000.0).round(3),
    parser_process_avg_ms: (parser_avg * 1000.0).round(3),
    last_enqueue_at: @server_queue_last_enqueue_at,
    last_dequeue_at: @server_queue_last_dequeue_at,
    reader_status: @reader_thread&.status,
    parser_status: @thread&.status
  }
  reset_server_queue_stats! if reset
  stats
end

.set_game_instance(game_type) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Creates and assigns a game-specific instance based on the game type.

Parameters:

  • game_type (String)

    the game identifier ("GS", "DR", or unknown)



512
513
514
# File 'documented/games.rb', line 512

def set_game_instance(game_type)
  @game_instance = GameInstanceFactory.create(game_type)
end

.settings_init_needed?Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns whether a new settings record needs to be initialized for the character.

Set to true when the server sends a malformed settingsInfo tag (from first connection with a client that is not Wrayth/StormFront).

Returns:

  • (Boolean)

    true if gameloader's PostLoad should seed a valid client record



473
474
475
# File 'documented/games.rb', line 473

def settings_init_needed?
  @@settings_init_needed
end

.shutdown_logLich::Common::ShutdownLog

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the logger for shutdown-related messages.

Returns:



1476
1477
1478
# File 'documented/games.rb', line 1476

def shutdown_log
  Lich::Common::ShutdownLog
end

.shutdown_reason_for_thread_exit(error) ⇒ Symbol

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Maps an error to a shutdown reason symbol for shutdown coordination.

Parameters:

  • error (StandardError)

    the error that caused thread exit

Returns:

  • (Symbol)

    one of :game_timeout, :connection_reset, :connection_pipe, :connection_aborted, :game_stream_desync, or :unrecoverable_game_thread_error



1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
# File 'documented/games.rb', line 1438

def shutdown_reason_for_thread_exit(error)
  case error
  when Errno::ETIMEDOUT, Errno::EWOULDBLOCK, IO::TimeoutError
    :game_timeout
  when Errno::ECONNRESET
    :connection_reset
  when Errno::EPIPE
    :connection_pipe
  when Errno::ECONNABORTED
    :connection_aborted
  when GameStreamDesyncError
    :game_stream_desync
  when ServerQueueOverflow
    :unrecoverable_game_thread_error
  else
    :unrecoverable_game_thread_error
  end
end

.start_cli_scriptsvoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Launches scripts passed via the --start-scripts command-line argument.

Parses comma-separated script names and calls Script.start on each. Sets @cli_scripts to true to prevent repeated launches. Logs the character login info.



1191
1192
1193
1194
1195
1196
1197
1198
1199
# File 'documented/games.rb', line 1191

def start_cli_scripts
  if (arg = ARGV.find { |a| a =~ /^\-\-start\-scripts=/ })
    arg.sub('--start-scripts=', '').split(',').each do |script_name|
      Script.start(script_name)
    end
  end
  @cli_scripts = true
  Lich.log("info: logged in as #{XMLData.game}:#{XMLData.name}")
end

.start_main_threadvoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Initializes the server queue and starts the socket reader and parser threads.



802
803
804
805
806
807
# File 'documented/games.rb', line 802

def start_main_thread
  @server_queue = SizedQueue.new(SERVER_QUEUE_CAPACITY)
  reset_server_queue_stats!
  start_socket_reader_thread
  start_server_processor_thread
end

.start_server_processor_threadvoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Starts the background thread that dequeues server strings and processes them.

Pops items from server_queue, parses XML, and runs downstream hooks.



1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
# File 'documented/games.rb', line 1018

def start_server_processor_thread
  @thread = Thread.new do
    begin
      loop do
        item = @server_queue.pop
        break if item.nil?

        server_string, enqueued_monotonic_at = unwrap_server_queue_item(item)
        record_server_queue_dequeue(enqueued_monotonic_at)
        parse_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        process_server_string(server_string)
        record_server_parser_timing(Process.clock_gettime(Process::CLOCK_MONOTONIC) - parse_started)
      end
    rescue StandardError => e
      log_error("Error processing server string", e)
      record_shutdown_reason(:unrecoverable_game_thread_error, source: :game_parser, detail: e.class)
      @socket.close rescue nil
    end
  end
  @thread.name = 'game parser' if @thread.respond_to?(:name=)
  @thread.priority = 4
end

.start_socket_reader_threadvoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Starts the background thread that reads lines from the game socket and enqueues them.

Handles socket timeouts, connection errors, and stream desync detection. Records performance metrics and runs SocketReadHook callbacks on each line received.



916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
# File 'documented/games.rb', line 916

def start_socket_reader_thread
  @reader_thread = Thread.new do
    consecutive_timeouts = 0
    max_consecutive_timeouts = MAX_CONSECUTIVE_READ_TIMEOUTS

    begin
      while true
        begin
          server_string = read_server_string

          if server_string.equal?(READ_TIMEOUT)
            raise IO::TimeoutError, "no game data for #{READ_TIMEOUT_SECONDS} seconds"
          end

          consecutive_timeouts = 0

          # Break if socket closed (gets returns nil)
          if server_string.nil?
            @remote_eof = true
            record_shutdown_reason(:game_eof, source: :game_reader)
            break
          end

          reader_process_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
          received_at = Time.now
          monotonic_received_at = reader_process_started
          @last_recv = received_at
          @_buffer.update(server_string) if defined?(TESTING) && TESTING
          hook_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
          Lich::Common::SocketReadHook.run(
            server_string,
            received_at: received_at,
            monotonic_received_at: monotonic_received_at
          ) if defined?(Lich::Common::SocketReadHook)
          hook_finished = Process.clock_gettime(Process::CLOCK_MONOTONIC)
          enqueue_started = hook_finished
          enqueue_server_string(server_string, enqueue_started)
          enqueue_finished = Process.clock_gettime(Process::CLOCK_MONOTONIC)
          record_server_reader_timing(
            hook_time: hook_finished - hook_started,
            enqueue_time: enqueue_finished - enqueue_started,
            process_time: enqueue_finished - reader_process_started
          )
        rescue Errno::ETIMEDOUT, Errno::EWOULDBLOCK, IO::TimeoutError
          consecutive_timeouts += 1

          shutdown_log.info("socket read timeout #{consecutive_timeouts}/#{max_consecutive_timeouts} (no game data for #{READ_TIMEOUT_SECONDS}s)")

          if consecutive_timeouts >= max_consecutive_timeouts
            total_timeout = total_read_timeout_seconds(max_consecutive_timeouts)
            shutdown_log.warning("game connection timed out after #{max_consecutive_timeouts} consecutive read timeouts (#{total_timeout}s)")
            raise IO::TimeoutError, "no game data for #{total_timeout} seconds"
          end

          # Check if socket is still alive
          if @socket.closed?
            shutdown_log.info("game socket is closed; exiting server thread")
            break
          end

          # Small sleep before retry
          sleep 0.1
          retry
        rescue Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED => conn_error
          # Connection was reset/broken - these are fatal
          shutdown_log.info("connection error: #{conn_error.class} - #{conn_error.message}")
          raise conn_error
        end
      end
    rescue StandardError => e
      if intentional_shutdown_close_error?(e)
        shutdown_log.info("server thread exiting after orderly user shutdown")
        next
      end

      # Handle any other errors
      should_continue = handle_thread_error(e)
      # Only retry if handle_thread_error says it's safe and socket is still open
      if should_continue && !@socket.closed? && $_CLIENT_.alive?
        shutdown_log.debug("retrying server thread after error")
        consecutive_timeouts = 0 # Reset counter on retry
        sleep 1 # Brief pause before retry
        retry
      else
        reason = shutdown_reason_for_thread_exit(e)
        record_shutdown_reason(reason, source: :game_reader, detail: e.class)
        shutdown_log.info("server thread exiting due to #{reason}")
      end
    ensure
      @server_queue << nil if @server_queue && @thread&.alive?
    end
  end
  @reader_thread.name = 'game socket reader' if @reader_thread.respond_to?(:name=)
  @reader_thread.priority = 5
end

.start_wrap_threadvoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Starts the wrap thread that issues an initial "look" command after login.

Also performs database vacuum if due. Sends "look" unless autostart has already run (or 6 seconds have elapsed with no server activity).



606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
# File 'documented/games.rb', line 606

def start_wrap_thread
  begin
    Lich.db_vacuum_if_due!(months: 6)
  rescue => e
    Lich.log "db_maint(startup): #{e.class}: #{e.message}"
  end

  @wrap_thread = Thread.new do
    @last_recv = Time.now
    until @@autostarted || (Time.now - @last_recv >= 6)
      break if @@autostarted
      sleep 0.2
    end

    puts 'look' unless @@autostarted
  end
end

.total_read_timeout_seconds(timeout_count = MAX_CONSECUTIVE_READ_TIMEOUTS) ⇒ Integer

Returns total elapsed no-data seconds represented by count.

Parameters:

  • timeout_count (Integer) (defaults to: MAX_CONSECUTIVE_READ_TIMEOUTS)

    number of consecutive read waits

Returns:

  • (Integer)

    total elapsed no-data seconds represented by count



1057
1058
1059
# File 'documented/games.rb', line 1057

def total_read_timeout_seconds(timeout_count = MAX_CONSECUTIVE_READ_TIMEOUTS)
  READ_TIMEOUT_SECONDS * timeout_count
end

.unwrap_server_queue_item(item) ⇒ Array(String, Numeric, nil)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Extracts a server string and its monotonic timestamp from a queue item.

Parameters:

  • item (String, Array)

    either a server string or a 2-element array of [string, monotonic_time]

Returns:

  • (Array(String, Numeric, nil))

    a 2-element array of [server_string, monotonic_time_or_nil]



901
902
903
904
905
906
907
# File 'documented/games.rb', line 901

def unwrap_server_queue_item(item)
  if item.is_a?(Array) && item.length == 2 && item[1].is_a?(Numeric)
    item
  else
    [item, nil]
  end
end