koraktor/steam-condenser-php

View on GitHub
lib/SteamCondenser/Servers/GameServer.php

Summary

Maintainability
C
1 day
Test Coverage
<?php
/**
 * This code is free software; you can redistribute it and/or modify it under
 * the terms of the new BSD License.
 *
 * Copyright (c) 2008-2015, Sebastian Staudt
 *
 * @license http://www.opensource.org/licenses/bsd-license.php New BSD License
 */

namespace SteamCondenser\Servers;

use SteamCondenser\Exceptions\SteamCondenserException;
use SteamCondenser\Servers\Packets\SteamPacket;
use SteamCondenser\Servers\Packets\A2SINFOPacket;
use SteamCondenser\Servers\Packets\A2SPLAYERPacket;
use SteamCondenser\Servers\Packets\A2SRULESPacket;
use SteamCondenser\Servers\Packets\S2AINFOBasePacket;
use SteamCondenser\Servers\Packets\S2APLAYERPacket;
use SteamCondenser\Servers\Packets\S2ARULESPacket;
use SteamCondenser\Servers\Packets\S2CCHALLENGEPacket;

/**
 * This class is subclassed by classes representing different game server
 * implementations and provides the basic functionality to communicate with
 * them using the common query protocol
 *
 * @author     Sebastian Staudt
 * @package    steam-condenser
 * @subpackage servers
 */
abstract class GameServer extends Server {

    const REQUEST_CHALLENGE = 0;
    const REQUEST_INFO      = 1;
    const REQUEST_PLAYER    = 2;
    const REQUEST_RULES     = 3;

    /**
     * @var int The challenge number to communicate with the server
     */
    protected $challengeNumber;

    /**
     * @var array Basic information about this server
     */
    protected $infoHash;

    /**
     * @var int The response time of this server
     */
    protected $ping;

    /**
     * @var SteamPlayer[] The players playing on this server
     */
    protected $playerHash;

    /**
     * @var bool whether the RCON connection is already authenticated
     */
    protected $rconAuthenticated;

    /**
     * @var array The settings applied on the server
     */
    protected $rulesHash;

    /**
     * @var \SteamCondenser\Servers\Sockets\SteamSocket The socket of to
     *      communicate with the server
     */
    protected $socket;

    /**
     * Parses the player attribute names supplied by <var>rcon status</var>
     *
     * @param string $statusHeader The header line provided by <var>rcon
     *        status</var>
     * @return array Split player attribute names
     * @see splitPlayerStatus()
     */
    protected function getPlayerStatusAttributes($statusHeader) {
        $statusAttributes = [];
        foreach(preg_split("/\s+/", $statusHeader) as $attribute) {
            if($attribute == 'connected') {
                $statusAttributes[] = 'time';
            } else if($attribute == 'frag') {
                $statusAttributes[] = 'score';
            } else {
                $statusAttributes[] = $attribute;
            }
        }

        return $statusAttributes;
    }

    /**
     * Splits the player status obtained with <var>rcon status</var>
     *
     * @param array $attributes The attribute names
     * @param string $playerStatus The status line of a single player
     * @return array The attributes with the corresponding values for this
     *         player
     * @see getPlayerStatusAttributes()
     */
    protected function splitPlayerStatus($attributes, $playerStatus) {
        if($attributes[0] != 'userid') {
            $playerStatus = preg_replace('/^\d+ +/', '', $playerStatus);
        }

        $firstQuote = strpos($playerStatus, '"');
        $lastQuote  = strrpos($playerStatus, '"');
        $data = [
            substr($playerStatus, 0, $firstQuote),
            substr($playerStatus, $firstQuote + 1, $lastQuote - 1 - $firstQuote),
            substr($playerStatus, $lastQuote + 1)
        ];

        $data = array_merge(
            array_filter(preg_split("/\s+/", trim($data[0]))),
            [$data[1]],
            preg_split("/\s+/", trim($data[2]))
        );
        $data = array_values($data);

        if(sizeof($attributes) > sizeof($data) &&
           in_array('state', $attributes)) {
            array_splice($data, 3, 0, [null, null, null]);
        } elseif(sizeof($attributes) < sizeof($data)) {
            unset($data[1]);
            $data = array_values($data);
        }

        $playerData = [];
        for($i = 0; $i < sizeof($data); $i ++) {
            $playerData[$attributes[$i]] = $data[$i];
        }

        return $playerData;
    }

    /**
     * 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 int $port The port the server is listening on
     * @throws SteamCondenserException if an host name cannot be resolved
     */
    public function __construct($address, $port = 27015) {
        parent::__construct($address, $port);

        $this->rconAuthenticated = false;
    }

    /**
     * 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
     * {@link updatePing()} regularly to stay up-to-date.
     *
     * @return int The latency of this server in milliseconds
     * @see updatePing()
     */
    public function getPing() {
        if($this->ping == null) {
            $this->updatePing();
        }

        return $this->ping;
    }

    /**
     * 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 {@link updatePlayers()} if you rely on
     * this information.
     *
     * @param string $rconPassword The RCON password of this server may be
     *        provided to gather more detailed information on the players, like
     *     STEAM_IDs.
     * @return array The players on this server
     * @see updatePlayers()
     */
    public function getPlayers($rconPassword = null) {
        if($this->playerHash == null) {
            $this->updatePlayers($rconPassword);
        }

        return $this->playerHash;
    }

    /**
     * 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
     * {@link updateRules()}.
     *
     * @return array The currently active server rules
     * @see updateRules()
     */
    public function getRules() {
        if($this->rulesHash == null) {
            $this->updateRules();
        }

        return $this->rulesHash;
    }

    /**
     * Returns an associative array 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
     * {@link updatePlayers()}, there's no need to call
     * {@link updateServerInfo()} very often.
     *
     * @return array Server attributes with their values
     * @see updateServerInfo()
     */
    public function getServerInfo() {
        if($this->infoHash == null) {
            $this->updateServerInfo();
        }

        return $this->infoHash;
    }

    /**
     * Initializes this server object with basic information
     *
     * @see updateChallengeNumber()
     * @see updatePing()
     * @see updateServerInfo()
     */
    public function initialize() {
        $this->updatePing();
        $this->updateServerInfo();
        $this->updateChallengeNumber();
    }

    /**
     * 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 int $requestType The type of request to send to the server
     * @param bool $repeatOnFailure 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.
     * @throws SteamCondenserException if either the request type or the
     *        response packet is not known
     */
    protected function handleResponseForRequest($requestType, $repeatOnFailure = true) {
        switch($requestType) {
            case self::REQUEST_CHALLENGE:
                $expectedResponse = '\SteamCondenser\Servers\Packets\S2CCHALLENGEPacket';
                $requestPacket    = new A2SPLAYERPacket();
                break;
            case self::REQUEST_INFO:
                $expectedResponse = '\SteamCondenser\Servers\Packets\S2AINFOBasePacket';
                $requestPacket    = new A2SINFOPacket();
                break;
            case self::REQUEST_PLAYER:
                $expectedResponse = '\SteamCondenser\Servers\Packets\S2APLAYERPacket';
                $requestPacket    = new A2SPLAYERPacket($this->challengeNumber);
                break;
            case self::REQUEST_RULES:
                $expectedResponse = '\SteamCondenser\Servers\Packets\S2ARULESPacket';
                $requestPacket    = new A2SRULESPacket($this->challengeNumber);
                break;
            default:
                throw new SteamCondenserException('Called with wrong request type.');
        }

        $this->socket->send($requestPacket);
        $responsePacket = $this->socket->getReply();

        if($responsePacket instanceof S2AINFOBasePacket) {
            $this->infoHash = $responsePacket->getInfo();
        } elseif($responsePacket instanceof S2APLAYERPacket) {
            $this->playerHash = $responsePacket->getPlayerHash();
        } elseif($responsePacket instanceof S2ARULESPacket) {
            $this->rulesHash = $responsePacket->getRulesArray();
        } elseif($responsePacket instanceof S2CCHALLENGEPacket) {
            $this->challengeNumber = $responsePacket->getChallengeNumber();
        } else {
            throw new SteamCondenserException('Response of type ' . get_class($responsePacket) . ' cannot be handled by this method.');
        }

        if(!($responsePacket instanceof $expectedResponse)) {
            $this->logger->info("Expected {$expectedResponse}, got " . get_class($responsePacket) . '.');
            if($repeatOnFailure) {
                $this->handleResponseForRequest($requestType, false);
            }
        }
    }

    /**
     * Returns whether the RCON connection to this server is already
     * authenticated
     *
     * @return bool <var>true</var> if the RCON connection is authenticated
     * @see rconAuth()
     */
    public function isRconAuthenticated() {
        return $this->rconAuthenticated;
    }

    /**
     * Authenticates the connection for RCON communication with the server
     *
     * @param string $password The RCON password of the server
     * @return bool whether authentication was successful
     * @see rconAuth()
     * @throws SteamCondenserException if a problem occurs while parsing the
     *         reply
     * @throws TimeoutException if the request times out
     */
    abstract public function rconAuth($password);

    /**
     * Remotely executes a command on the server via RCON
     *
     * @param string $command The command to execute on the server via RCON
     * @return string The output of the executed command
     * @see rconExec()
     * @throws SteamCondenserException if a problem occurs while parsing the
     *         reply
     * @throws TimeoutException if the request times out
     */
    abstract public function rconExec($command);

    /**
     * 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
     * {@link handleResponseForRequest()} will automatically get the challenge
     * number when the server assigns a new one.
     *
     * @see handleResponseForRequest()
     * @see initialize()
     */
    public function updateChallengeNumber() {
        $this->handleResponseForRequest(self::REQUEST_CHALLENGE);
    }

    /**
     * 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 int The latency of this server in milliseconds
     * @see getPing()
     * @see initialize()
     */
    public function updatePing() {
        $this->socket->send(new A2SINFOPacket());
        $startTime = microtime(true);
        $this->socket->getReply();
        $endTime = microtime(true);
        $this->ping = intval(round(($endTime - $startTime) * 1000));

        return $this->ping;
    }

    /**
     * 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 $rconPassword The RCON password of this server may be
     *        provided to gather more detailed information on the players, like
     *        STEAM_IDs.
     * @see getPlayers()
     * @see handleResponseForRequest()
     */
    public function updatePlayers($rconPassword = null) {
        $this->handleResponseForRequest(self::REQUEST_PLAYER);

        if(!$this->rconAuthenticated) {
            if($rconPassword == null) {
                return;
            }
            $this->rconAuth($rconPassword);
        }

        $players = [];
        foreach(explode("\n", $this->rconExec('status')) as $line) {
            if(strpos($line, '#') === 0 && $line != '#end') {
                $players[] = trim(substr($line, 1));
            }
        }
        $attributes = $this->getPlayerStatusAttributes(array_shift($players));

        foreach($players as $player) {
            $playerData = $this->splitPlayerStatus($attributes, $player);
            if(array_key_exists($playerData['name'], $this->playerHash)) {
                $this->playerHash[$playerData['name']]->addInformation($playerData);
            }
        }
    }

    /**
     * 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 getRules()
     * @see handleResponseForRequest()
     */
    public function updateRules() {
        $this->handleResponseForRequest(self::REQUEST_RULES);
    }

    /**
     * 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
     * {@link updatePlayers()}, there's no need to call this method very often.
     *
     * @see getServerInfo()
     * @see handleResponseForRequest()
     * @see initialize()
     */
    public function updateServerInfo() {
        $this->handleResponseForRequest(self::REQUEST_INFO);
    }

    /**
     * Returns a human-readable text representation of the server
     *
     * @return string Available information about the server in a
     *         human-readable format
     */
    public function __toString() {
        $returnString = '';

        $returnString .= "Ping: {$this->ping}\n";
        $returnString .= "Challenge number: {$this->challengeNumber}\n";

        if($this->infoHash != null) {
            $returnString .= "Info:\n";
            foreach($this->infoHash as $key => $value) {
                if(is_array($value)) {
                    $returnString .= "  {$key}:\n";
                    foreach($value as $subKey => $subValue) {
                        $returnString .= "    {$subKey} = {$subValue}\n";
                    }
                } else {
                    $returnString .= "  {$key}: {$value}\n";
                }
            }
        }

        if($this->playerHash != null) {
            $returnString .= "Players:\n";
            foreach($this->playerHash as $player) {
                $returnString .= "  {$player}\n";
            }
        }

        if($this->rulesHash != null) {
            $returnString .= "Rules:\n";
            foreach($this->rulesHash as $key => $value) {
                $returnString .= "  {$key}: {$value}\n";
            }
        }

        return $returnString;
    }
}