Class: Lich::GameBase::Game
- Inherits:
-
Object
- Object
- Lich::GameBase::Game
- Defined in:
- documented/games.rb
Overview
Base Game class with common functionality
Direct Known Subclasses
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
-
._buffer ⇒ Object
readonly
Returns the value of attribute _buffer.
-
.buffer ⇒ Object
readonly
Returns the value of attribute buffer.
-
.game_instance ⇒ Object
readonly
Returns the value of attribute game_instance.
-
.reader_thread ⇒ Object
readonly
Returns the value of attribute reader_thread.
-
.server_queue ⇒ Object
readonly
Returns the value of attribute server_queue.
-
.thread ⇒ Object
readonly
Returns the value of attribute thread.
Class Method Summary collapse
-
._gets ⇒ String?
private
Reads the next line from the internal test/debug buffer.
-
._puts(str) ⇒ true?
Writes a string to the game server socket.
-
.autostarted? ⇒ Boolean
Returns whether the autostart script has already been launched.
-
.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).
-
.close ⇒ void
Closes the game socket and stops the reader and parser threads.
-
.closed? ⇒ Boolean
Returns whether the game socket is closed or uninitialized.
-
.connection_disruption_log_message(error) ⇒ String
private
Formats a connection disruption error for logging.
-
.display_ruby_warning ⇒ void
private
Displays a formatted warning table when the Ruby version is below the recommended minimum.
-
.enqueue_server_string(server_string, enqueued_monotonic_at) ⇒ void
private
Pushes a server message onto the parser queue with timing metadata.
-
.fix_invalid_settings_info(server_string) ⇒ Object
The server sends a malformed <settingsInfo ...
-
.gets ⇒ String?
Reads the next line from the main game buffer.
-
.handle_autostart ⇒ void
private
Initializes the game on first login: checks for version updates, syncs script repositories in a background thread, and launches the autostart script.
-
.handle_thread_error(error) ⇒ Boolean
private
Evaluates whether a reader thread error is recoverable and logs appropriately.
-
.initialize_buffers ⇒ void
private
Initializes or resets all game connection buffers, queues, and internal state.
-
.intentional_shutdown_close_error?(error) ⇒ Boolean
private
Returns whether an error represents an orderly user-initiated shutdown.
- .log_error(message, error) ⇒ Object protected
-
.open(host, port) ⇒ TCPSocket
Opens the TCP connection to the game server and starts the socket's wrap and main reader threads.
-
.open_with_timeout(host, port, timeout = 30) ⇒ void
Connects to the game server on a background thread, enforcing a connect timeout that a bare Game.open cannot.
-
.prefix_origin_sentinel(string) ⇒ String
private
Prepends the origin sentinel marker to each line for frontends that support tagged output.
-
.process_downstream_hooks(server_string) ⇒ void
private
Runs the DownstreamHook chain and processes room information and client output.
-
.process_room_information(alt_string) ⇒ String
private
Detects room-name lines and flags them for room display modification.
-
.process_server_string(server_string) ⇒ void
private
Processes a raw server string through cleaning, XML parsing, game-specific parsing, and downstream hooks.
-
.process_xml_data(server_string) ⇒ void
private
Parses a server string as XML into XMLData and repairs common malformations.
-
.puts(str) ⇒ void
private
Sends a command to the game server, logging it to the client and $CLIENTBUFFER.
-
.read_server_string(read_timeout: READ_TIMEOUT_SECONDS) ⇒ String, ...
Reads one game-server line after an explicit readiness wait.
-
.recognized_connection_disruption?(error) ⇒ Boolean
private
Returns whether an error is a known connection disruption or stream problem.
-
.record_server_parser_timing(parse_time) ⇒ nil
private
Records the XML parsing time for a server message.
-
.record_server_queue_dequeue(enqueued_monotonic_at = nil) ⇒ nil
private
Records a message dequeue event and updates latency statistics.
-
.record_server_queue_enqueue ⇒ nil
private
Records a message enqueue event and updates queue depth statistics.
-
.record_server_reader_timing(hook_time:, enqueue_time:, process_time:) ⇒ nil
private
Records socket reader thread timing metrics (hook, enqueue, and process times).
-
.record_shutdown_reason(reason, source:, detail: nil) ⇒ void
private
Records the reason and source of game shutdown to the ShutdownCoordinator.
-
.remote_eof? ⇒ Boolean
Whether the game server closed its side of the socket.
-
.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').
-
.reset_server_queue_stats! ⇒ nil
private
Resets the parser queue and timing statistics to initial values.
-
.send_to_client(alt_string) ⇒ void
private
Sends the final outbound string to the connected frontend client(s).
-
.server_queue_stats(reset: false) ⇒ Hash
private
Returns performance metrics for the game server message queue and XML parser.
-
.set_game_instance(game_type) ⇒ void
private
Creates and assigns a game-specific instance based on the game type.
-
.settings_init_needed? ⇒ Boolean
private
Returns whether the settings XML needs initialization (e.g., for new characters).
-
.shutdown_log ⇒ Lich::Common::ShutdownLog
private
Returns the shutdown logging utility.
-
.shutdown_reason_for_thread_exit(error) ⇒ Symbol
private
Maps an exception type to a shutdown reason code for logging.
-
.start_cli_scripts ⇒ void
private
Starts user-specified scripts passed via the --start-scripts command-line argument.
-
.start_main_thread ⇒ void
private
Starts the socket reader thread and server processor thread.
-
.start_server_processor_thread ⇒ void
private
Starts the background thread that dequeues and parses server messages.
-
.start_socket_reader_thread ⇒ void
private
Starts the background thread that reads raw data from the game socket.
-
.start_wrap_thread ⇒ void
private
Starts a background thread that sends the initial 'look' command after connection.
-
.total_read_timeout_seconds(timeout_count = MAX_CONSECUTIVE_READ_TIMEOUTS) ⇒ Integer
Total elapsed no-data seconds represented by count.
-
.unwrap_server_queue_item(item) ⇒ Array<(String, Numeric, nil)>
private
Unpacks a queued item into [server_string, enqueued_monotonic_at] tuple.
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.
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.
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.
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 || STREAM_DESYNC_ERRORS.any? { |pattern| pattern.match?() } 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.
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".
1548 1549 1550 1551 1552 |
# File 'documented/games.rb', line 1548 def (error) return "GameStreamDesyncError: #{error..lines.first&.strip}" if error.is_a?(GameStreamDesyncError) "#{error.class}: #{error.}" 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.
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.
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.}" 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.
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: #{(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).
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(, error) Lich.log "#{}: #{error}\n\t#{error.backtrace.join("\n\t")}" end |
.open(host, port) ⇒ TCPSocket
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.
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.
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.
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.
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.
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.
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.
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. ’) 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.}); 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.
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.
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.
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.
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.
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.
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.
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.
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.}") end |
.remote_eof? ⇒ Boolean
Returns 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? { || NO_ATTRIBUTE_VALUE_ERROR.match?() } 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.
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.
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.
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.
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.
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.
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.
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.}") 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.}" 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.
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.
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 |