lib/steam-condenser/servers/game_server.rb
# This code is free software; you can redistribute it and/or modify it under
# the terms of the new BSD License.
#
# Copyright (c) 2008-2013, Sebastian Staudt
require 'steam-condenser/error'
require 'steam-condenser/error/timeout'
require 'steam-condenser/servers/base_server'
require 'steam-condenser/servers/steam_player'
require 'steam-condenser/servers/packets/a2s_info_packet'
require 'steam-condenser/servers/packets/a2s_player_packet'
require 'steam-condenser/servers/packets/a2s_rules_packet'
require 'steam-condenser/servers/packets/a2s_serverquery_getchallenge_packet'
require 'steam-condenser/servers/packets/s2a_info_base_packet'
require 'steam-condenser/servers/packets/s2a_player_packet'
require 'steam-condenser/servers/packets/s2a_rules_packet'
require 'steam-condenser/servers/packets/s2c_challenge_packet'
module SteamCondenser
module Servers
# This module is included by classes representing different game server
# implementations and provides the basic functionality to communicate with
# them using the common query protocol
#
# @author Sebastian Staudt
module GameServer
include BaseServer
# Parses the player attribute names supplied by `rcon status`
#
# @param [String] status_header The header line provided by `rcon status`
# @return [Array<Symbol>] Split player attribute names
# @see .split_player_status
def self.player_status_attributes(status_header)
status_header.split.map do |attribute|
case attribute
when 'connected'
:time
when 'frag'
:score
else
attribute.to_sym
end
end
end
# Splits the player status obtained with `rcon status`
#
# @param [Array<Symbol>] attributes The attribute names
# @param [String] player_status The status line of a single player
# @return [Hash<Symbol, String>] The attributes with the corresponding values
# for this player
# @see .player_status_attributes
def self.split_player_status(attributes, player_status)
player_status.sub! /^\d+ +/, '' if attributes.first != :userid
first_quote = player_status.index '"'
last_quote = player_status.rindex '"'
data = [
player_status[0, first_quote],
player_status[first_quote + 1..last_quote - 1],
player_status[last_quote + 1..-1]
]
data = [ data[0].split, data[1], data[2].split ]
data.flatten!
if attributes.size > data.size && attributes.include?(:state)
data.insert 3, nil, nil, nil
elsif attributes.size < data.size
data.delete_at 1
end
player_data = {}
data.each_index do |i|
player_data[attributes[i]] = data[i]
end
player_data
end
# Creates a new instance of a game server object
#
# @param [String] address Either an IP address, a DNS name or one of them
# combined with the port number. If a port number is given, e.g.
# 'server.example.com:27016' it will override the second argument.
# @param [Fixnum] port The port the server is listening on
# @raise [Error] if an host name cannot be resolved
def initialize(address, port = 27015)
super
@rcon_authenticated = false
end
# Returns the last measured response time of this server
#
# If the latency hasn't been measured yet, it is done when calling this
# method for the first time.
#
# If this information is vital to you, be sure to call {#update_ping}
# regularly to stay up-to-date.
#
# @return [Fixnum] The latency of this server in milliseconds
# @see #update_ping
def ping
update_ping if @ping.nil?
@ping
end
# Returns a list of players currently playing on this server
#
# If the players haven't been fetched yet, it is done when calling this
# method for the first time.
#
# As the players and their scores change quite often be sure to update this
# list regularly by calling {#update_players} if you rely on this
# information.
#
# @param [String] rcon_password The RCON password of this server may be
# provided to gather more detailed information on the players, like
# STEAM_IDs.
# @return [Hash] The players on this server
# @see update_players
def players(rcon_password = nil)
update_players(rcon_password) if @player_hash.nil?
@player_hash
end
# Authenticates the connection for RCON communication with the server
#
# @abstract Must be be implemented by including classes to handle the
# authentication
# @param [String] password The RCON password of the server
# @return [Boolean] whether the authentication was successful
# @see #rcon_exec
def rcon_auth(password)
raise NotImplementedError
end
# Returns whether the RCON connection to this server is already authenticated
#
# @return [Boolean] `true` if the RCON connection is authenticated
# @see #rcon_auth
def rcon_authenticated?
@rcon_authenticated
end
# Remotely executes a command on the server via RCON
#
# @abstract Must be be implemented by including classes to handle the command
# execution
# @param [String] command The command to execute on the server
# @return [String] The output of the executed command
# @see #rcon_auth
def rcon_exec(command)
raise NotImplementedError
end
# Returns the settings applied on the server. These settings are also called
# rules.
#
# If the rules haven't been fetched yet, it is done when calling this method
# for the first time.
#
# As the rules usually don't change often, there's almost no need to update
# this hash. But if you need to, you can achieve this by calling
# {#update_rules}.
#
# @return [Hash<String, String>] The currently active server rules
# @see #update_rules
def rules
update_rules if @rules_hash.nil?
@rules_hash
end
# Returns a hash with basic information on the server.
#
# If the server information haven't been fetched yet, it is done when
# calling this method for the first time.
#
# The server information usually only changes on map change and when players
# join or leave. As the latter changes can be monitored by calling
# {#update_players}, there's no need to call {#update_server_info} very
# often.
#
# @return [Hash] Server attributes with their values
# @see #update_server_info
def server_info
update_server_info if @info_hash.nil?
@info_hash
end
# Sends the specified request to the server and handles the returned response
#
# Depending on the given request type this will fill the various data
# attributes of the server object.
#
# @param [Symbol] request_type The type of request to send to the server
# @param [Boolean] repeat_on_failure Whether the request should be repeated,
# if the replied packet isn't expected. This is useful to handle
# missing challenge numbers, which will be automatically filled in,
# although not requested explicitly.
# @raise [Error] if either the request type or the response
# packet is not known
def handle_response_for_request(request_type, repeat_on_failure = true)
case request_type
when :challenge then
request_packet = Packets::A2S_PLAYER_Packet.new
expected_response = Packets::S2C_CHALLENGE_Packet
when :info then
request_packet = Packets::A2S_INFO_Packet.new
expected_response = Packets::S2A_INFO_BasePacket
when :players then
request_packet = Packets::A2S_PLAYER_Packet.new(@challenge_number)
expected_response = Packets::S2A_PLAYER_Packet
when :rules then
request_packet = Packets::A2S_RULES_Packet.new(@challenge_number)
expected_response = Packets::S2A_RULES_Packet
else
raise SteamCondenser::Error, 'Called with wrong request type.'
end
@socket.send_packet request_packet
response_packet = @socket.reply
if response_packet.kind_of? Packets::S2A_INFO_BasePacket
@info_hash = response_packet.info
elsif response_packet.kind_of? Packets::S2A_PLAYER_Packet
@player_hash = response_packet.player_hash
elsif response_packet.kind_of? Packets::S2A_RULES_Packet
@rules_hash = response_packet.rules_hash
elsif response_packet.kind_of? Packets::S2C_CHALLENGE_Packet
@challenge_number = response_packet.challenge_number
else
raise SteamCondenser::Error, "Response of type #{response_packet.class} cannot be handled by this method."
end
unless response_packet.kind_of? expected_response
if log.info?
expected_class = expected_response.class.name[/[^:]*\z/]
response_class = response_packet.class.name[/[^:]*\z/]
log.info "Expected #{expected_class}, got #{response_class}."
end
handle_response_for_request(request_type, false) if repeat_on_failure
end
end
# Initializes this server object with basic information
#
# @see #update_challenge_number
# @see #update_ping
# @see #update_server_info
def init
update_ping
update_server_info
update_challenge_number
end
# Sends a A2S_PLAYERS request to the server and updates the players' data for
# this server
#
# As the players and their scores change quite often be sure to update this
# list regularly by calling this method if you rely on this
# information.
#
# @param [String] rcon_password The RCON password of this server may be
# provided to gather more detailed information on the players, like
# STEAM_IDs.
# @see #handle_response_for_request
# @see #players
def update_players(rcon_password = nil)
handle_response_for_request :players
unless @rcon_authenticated
return if rcon_password.nil?
rcon_auth rcon_password
end
players = rcon_exec('status').lines.select do |line|
line.start_with?('#') && line != "#end\n"
end.map do |line|
line[1..-1].strip
end
attributes = GameServer.player_status_attributes players.shift
players.each do |player|
player_data = GameServer.split_player_status(attributes, player)
player_name = player_data[:name]
if @player_hash.key? player_name
@player_hash[player_name].add_info player_data
end
end
end
# Sends a A2S_RULES request to the server and updates the rules of this
# server
#
# As the rules usually don't change often, there's almost no need to update
# this hash. But if you need to, you can achieve this by calling this method.
#
# @see #handle_response_for_request
# @see #rules
def update_rules
handle_response_for_request :rules
end
# Sends a A2S_INFO request to the server and updates this server's basic
# information
#
# The server information usually only changes on map change and when players
# join or leave. As the latter changes can be monitored by calling
# {#update_players}, there's no need to call this method very often.
#
# @see #handle_response_for_request
# @see #server_info
def update_server_info
handle_response_for_request :info
end
# Sends a A2S_SERVERQUERY_GETCHALLENGE request to the server and updates the
# challenge number used to communicate with this server
#
# There's usually no need to call this method explicitly, because
# {#handle_response_for_request} will automatically get the challenge number
# when the server assigns a new one.
#
# @see #handle_response_for_request
# @see #init
def update_challenge_number
handle_response_for_request :challenge
end
# Sends a A2S_INFO request to the server and measures the time needed for the
# reply
#
# If this information is vital to you, be sure to call this method regularly
# to stay up-to-date.
#
# @return [Fixnum] The latency of this server in milliseconds
# @see #ping
def update_ping
@socket.send_packet Packets::A2S_INFO_Packet.new
start_time = Time.now
@socket.reply
end_time = Time.now
@ping = (end_time - start_time) * 1000
end
# Returns a human-readable text representation of the server
#
# @return [String] Available information about the server in a human-readable
# format
def to_s
return_string = ''
return_string << "Ping: #@ping\n"
return_string << "Challenge number: #@challenge_number\n"
unless @info_hash.nil?
return_string << "Info:\n"
@info_hash.each do |key, value|
return_string << " #{key}: #{value.inspect}\n"
end
end
unless @player_hash.nil?
return_string << "Players:\n"
@player_hash.each_value do |player|
return_string << " #{player}\n"
end
end
unless @rules_hash.nil?
return_string << "Rules:\n"
@rules_hash.each do |key, value|
return_string << " #{key}: #{value}\n"
end
end
return_string
end
end
end
end