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

._buffer ⇒ Object (readonly)

Returns the value of attribute _buffer.



451
452
453
# File 'documented/games.rb', line 451

def _buffer
  @_buffer
end

.buffer ⇒ Object (readonly)

Returns the value of attribute buffer.



451
452
453
# File 'documented/games.rb', line 451

def buffer
  @buffer
end

.game_instance ⇒ Object (readonly)

Returns the value of attribute game_instance.



451
452
453
# File 'documented/games.rb', line 451

def game_instance
  @game_instance
end

.reader_thread ⇒ Object (readonly)

Returns the value of attribute reader_thread.



451
452
453
# File 'documented/games.rb', line 451

def reader_thread
  @reader_thread
end

.server_queue ⇒ Object (readonly)

Returns the value of attribute server_queue.



451
452
453
# File 'documented/games.rb', line 451

def server_queue
  @server_queue
end

.thread ⇒ Object (readonly)

Returns the value of attribute thread.



451
452
453
# File 'documented/games.rb', line 451

def thread
  @thread
end

Class Method Details

._gets ⇒ 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.

Reads the next line from the internal test/debug buffer.

Used only during testing; otherwise identical to .gets.

Returns:

  • (String, nil) —

    the next available line, or nil if the buffer is empty



800
801
802
# File 'documented/games.rb', line 800

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



751
752
753
754
755
756
757
758
759
# File 'documented/games.rb', line 751

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

Returns whether the autostart script has already been launched.

Returns:

  • (Boolean) —

    true if autostart has completed, false otherwise



456
457
458
# File 'documented/games.rb', line 456

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.



1286
1287
1288
1289
1290
1291
# File 'documented/games.rb', line 1286

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

.close ⇒ void

This method returns an undefined value.

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

Does not raise if already closed; errors are silently absorbed.



736
737
738
739
740
741
742
# File 'documented/games.rb', line 736

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

.closed? ⇒ Boolean

Returns whether the game socket is closed or uninitialized.

Returns:

  • (Boolean) —

    true if the socket is nil or closed



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

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.

GameStreamDesyncError gets special formatting (first line only); other errors are formatted as "Class: message".

Parameters:

  • error (StandardError) —

    the exception to format

Returns:

  • (String) —

    a log-ready error description



1548
1549
1550
1551
1552
# File 'documented/games.rb', line 1548

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_warning ⇒ 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.

Displays a formatted warning table when the Ruby version is below the recommended minimum.

Includes the current version, recommended version, and a link to game-specific documentation for upgrading.



1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
# File 'documented/games.rb', line 1185

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.

Pushes a server message onto the parser queue with timing metadata.

Parameters:

  • server_string (String) —

    the message to enqueue

  • enqueued_monotonic_at (Numeric) —

    monotonic clock timestamp when enqueued

Raises:



837
838
839
840
841
842
# File 'documented/games.rb', line 837

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).



1323
1324
1325
1326
1327
1328
1329
1330
# File 'documented/games.rb', line 1323

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

.gets ⇒ String?

Reads the next line from the main game buffer.

Returns:

  • (String, nil) —

    the next available line, or nil if the buffer is empty



790
791
792
# File 'documented/games.rb', line 790

def gets
  @buffer.gets
end

.handle_autostart ⇒ 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.

Initializes the game on first login: checks for version updates, syncs script repositories in a background thread, and launches the autostart script.

Displays a Ruby version warning if the installed version is below the recommended minimum. Blocks up to 10 seconds waiting for XMLData.name to be populated before syncing repositories.



1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
# File 'documented/games.rb', line 1143

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 whether a reader thread error is recoverable and logs appropriately.

Timeout and connection errors are fatal (returns false). Stream desync and queue overflow are fatal (returns false). Unknown errors are tentatively retryable unless the socket/client is closed or a fatal error pattern is detected.

Parameters:

  • error (StandardError) —

    the exception from the reader thread

Returns:

  • (Boolean) —

    true if the reader should retry, false if it should exit



1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
# File 'documented/games.rb', line 1417

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_buffers ⇒ 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.

Initializes or resets all game connection buffers, queues, and internal state.

Called on startup and on reconnection. Sets up socket-independent state tracking (autostart flag, settings init flag, script tracking). Socket configuration happens in .open after this completes.



487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'documented/games.rb', line 487

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.

Returns whether an error represents an orderly user-initiated shutdown.

True only when ShutdownCoordinator indicates orderly exit, the socket is closed, and the error is a close-related exception (EBADF, stream closed, bad file descriptor).

Parameters:

  • error (StandardError) —

    the exception to evaluate

Returns:

  • (Boolean) —

    true if this is an expected orderly-shutdown error



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

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)



1556
1557
1558
# File 'documented/games.rb', line 1556

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:



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
567
568
569
570
# File 'documented/games.rb', line 529

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:



584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
# File 'documented/games.rb', line 584

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.

Prepends the origin sentinel marker to each line for frontends that support tagged output.

Parameters:

  • string (String) —

    the multiline string to prefix

Returns:

  • (String) —

    the prefixed string, each line beginning with the sentinel



465
466
467
# File 'documented/games.rb', line 465

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 the DownstreamHook chain and processes room information and client output.

Executes game-specific frontend conversions (genie/frostbite room number formatting, StormFront-to-Wiz stream conversion), injects the origin sentinel if supported, and sends the rewritten string to the client.

Parameters:

  • server_string (String) —

    the server string after XML parsing



1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
# File 'documented/games.rb', line 1341

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) ⇒ 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.

Detects room-name lines and flags them for room display modification.

Sets @room_number_after_ready to true when a style=roomName tag is detected, signaling that the next prompt should trigger room display rewriting.

Parameters:

  • alt_string (String) —

    the outbound server string

Returns:

  • (String) —

    the unmodified server string



1380
1381
1382
1383
1384
1385
1386
1387
1388
# File 'documented/games.rb', line 1380

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.

Processes a raw server string through cleaning, XML parsing, game-specific parsing, and downstream hooks.

Detects game type from XML and lazily loads game-specific modules. Routes the string to the game instance's clean_serverstring and process_game_specific_data methods, parses it as XML into XMLData, inventories items, and fires downstream hooks. Handles autostart, CLI script starting, and socket reader hooks.

Parameters:

  • server_string (String) —

    the raw server string (one XML line)



1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
# File 'documented/games.rb', line 1086

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 /

  # Passively capture the inventoryManager extended feed (read-only tap;
  # returns the string unchanged and never raises -- see
  # Lich::Common::Inventory.observe). Runs on this parser thread.
  Lich::Common::Inventory.observe(server_string) if defined?(Lich::Common::Inventory)

  # 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 a server string as XML into XMLData and repairs common malformations.

Uses Ox in SAX mode with permissive parsing (no strict validation). Detects stream desync via Ox error callbacks and resets XMLData on truncation. Repairs malformed attribute values (settingsInfo space-not-found bug, nested quotes in dialog titles) via repair_malformed_attributes_and_reparse. Runs game-specific parsing and downstream script hooks after successful parse.

Parameters:

  • server_string (String) —

    the raw server XML string

Raises:



1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
# File 'documented/games.rb', line 1236

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, logging it to the client and $CLIENTBUFFER.

Routes the command through the socket reader hook, records it in $LASTUPSTREAM, and optionally echoes it to the screen (suppressed if the calling script has silent: true). Prepends the configured command prefix.

Parameters:

  • str (String) —

    the command to send (prefix-less)



770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
# File 'documented/games.rb', line 770

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



1063
1064
1065
1066
1067
# File 'documented/games.rb', line 1063

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.

Returns whether an error is a known connection disruption or stream problem.

Recognizes timeout, connection reset, pipe, abort, and stream desync errors.

Parameters:

  • error (StandardError) —

    the exception to evaluate

Returns:

  • (Boolean) —

    true if the error is a recognized connection issue



1530
1531
1532
1533
1534
1535
1536
1537
1538
# File 'documented/games.rb', line 1530

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 the XML parsing time for a server message.

Parameters:

  • parse_time (Numeric) —

    seconds spent parsing the message

Returns:

  • (nil)


896
897
898
899
900
901
# File 'documented/games.rb', line 896

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 a message dequeue event and updates latency statistics.

If enqueued_monotonic_at is provided, computes and records the queue wait time.

Parameters:

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

    the monotonic timestamp when the item was enqueued

Returns:

  • (nil)


875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
# File 'documented/games.rb', line 875

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_enqueue ⇒ 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 a message enqueue event and updates queue depth statistics.

Returns:

  • (nil)


821
822
823
824
825
826
827
828
# File 'documented/games.rb', line 821

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 socket reader thread timing metrics (hook, enqueue, and process times).

Accumulates both last, max, and total times for averaging.

Parameters:

  • hook_time (Numeric) —

    seconds spent in the socket read hook

  • enqueue_time (Numeric) —

    seconds spent pushing to the queue

  • process_time (Numeric) —

    seconds spent on total reader processing

Returns:

  • (nil)


853
854
855
856
857
858
859
860
861
862
863
864
865
866
# File 'documented/games.rb', line 853

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 reason and source of game shutdown to the ShutdownCoordinator.

Logs a warning if recording fails; does not raise.

Parameters:

  • reason (Symbol) —

    the shutdown reason code

  • source (Symbol) —

    the subsystem that detected shutdown (:game_reader, :game_parser)

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

    optional exception class for context



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

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



637
638
639
# File 'documented/games.rb', line 637

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.



1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
# File 'documented/games.rb', line 1303

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 the parser queue and timing statistics to initial values.

Returns:

  • (nil)


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

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.

Sends the final outbound string to the connected frontend client(s).

Prioritizes detachable (multi-client) registry over the default client. Skips dead clients and handles empty registry gracefully.

Parameters:

  • alt_string (String) —

    the string to send



1398
1399
1400
1401
1402
1403
1404
1405
1406
# File 'documented/games.rb', line 1398

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 performance metrics for the game server message queue and XML parser.

Includes queue depth, throughput, latency (in seconds and milliseconds), and thread status. Values are cumulative since the last reset.

Parameters:

  • reset (Boolean) (defaults to: false) —

    if true, zeroes all stats after returning them

Returns:

  • (Hash) —

    statistics including :depth, :max_depth, :enqueued, :dequeued, :max_wait_ms, :avg_wait_ms, :reader_hook_max_ms, :parser_process_avg_ms, etc.



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
727
728
729
# File 'documented/games.rb', line 679

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" or "DR")



516
517
518
# File 'documented/games.rb', line 516

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 the settings XML needs initialization (e.g., for new characters).

Set to true when a malformed settingsInfo tag is detected and repaired.

Returns:

  • (Boolean) —

    true if settings initialization is required



475
476
477
# File 'documented/games.rb', line 475

def settings_init_needed?
  @@settings_init_needed
end

.shutdown_log ⇒ Lich::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 shutdown logging utility.

Returns:



1502
1503
1504
# File 'documented/games.rb', line 1502

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 exception type to a shutdown reason code for logging.

Parameters:

  • error (StandardError) —

    the exception that caused thread exit

Returns:

  • (Symbol) —

    a shutdown reason code (:game_timeout, :connection_reset, etc.)



1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
# File 'documented/games.rb', line 1462

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_scripts ⇒ 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.

Starts user-specified scripts passed via the --start-scripts command-line argument.

Parses comma-separated script names from the argument and launches each via Script.start.



1214
1215
1216
1217
1218
1219
1220
1221
1222
# File 'documented/games.rb', line 1214

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_thread ⇒ 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.

Starts the socket reader thread and server processor thread.

Called by .open after the socket is configured. Resets queue statistics.



810
811
812
813
814
815
# File 'documented/games.rb', line 810

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_thread ⇒ 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.

Starts the background thread that dequeues and parses server messages.

Pops messages from the server queue and calls process_server_string for each. Logs and shuts down the connection on unrecoverable parsing errors.



1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
# File 'documented/games.rb', line 1032

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_thread ⇒ 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.

Starts the background thread that reads raw data from the game socket.

Reads lines from the socket with timeout handling, runs the socket read hook, and enqueues messages for the parser. Handles consecutive timeouts (disconnects after max threshold), connection errors, and stream desync. Records EOF when the server closes the connection. Retries transient errors unless the socket is closed or a fatal condition detected.



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
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
# File 'documented/games.rb', line 929

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_thread ⇒ 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.

Starts a background thread that sends the initial 'look' command after connection.

Waits 6 seconds for the autostart script to launch; if it doesn't start by then, sends 'look' to prompt the server for a room description. Also vacuums the database on startup if maintenance is due.



611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
# File 'documented/games.rb', line 611

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



1071
1072
1073
# File 'documented/games.rb', line 1071

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.

Unpacks a queued item into [server_string, enqueued_monotonic_at] tuple.

Items are stored as arrays with timing metadata, but handles raw strings for backward compatibility by pairing them with nil timestamp.

Parameters:

  • item (Array, String) —

    the queued item

Returns:

  • (Array<(String, Numeric, nil)>) —

    [server_string, timestamp_or_nil]



911
912
913
914
915
916
917
# File 'documented/games.rb', line 911

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