Pull to refresh

Написание бота для Grepolis

Reading time11 min
Views20K
image Добрый день. В этой статье я опишу написание бота для онлайн mmo strategy игры Grepolis. Учтите, что правилами игры использование подобных програм запрещено, за это банят, и не безпричинно. Просто у меня хобби писать боты для игр. А писать не запрещено. Кому интересны логика и реализация, прошу под кат.

Как всегда, в начале ссылка на исходный код.

Мне нравится Grepolis, хорошая игра. Но чтобы там выжить, нужно каждые 5 минут собирать дань с деревень. А я весь день занят на основной работе, поэтому главной целью написания бота было как-раз собирать дань. Потом добавилось автоулучшение деревень(тогда больше прибыли дают), автопостройка(чтобы ночью, когда я сплю, очередь постройки не пустела). Насколько я понял, эти функции приносят основную часть дохода администрации игры. Наверное потому игроков, что используют ботов, так часто банят.

Сам по себе бот играть не будет, на него возложены только вспомогательные функции. И вообще, лучше не бросайте его одного на длительное время — могут забанить.

Чесно говоря это уже вторая по счету версия бота, как и первая написана на Perl. Первая версия по крону раз в пять минут собирала ресурсы, строилась и завершалась. И это неплохо работало, я попал в топ игроков, все было хорошо. Но потом игра надоела и я забил, когда через время снова начал играть — меня забанили. Видимо, добавили функций обнаружения ботов. Значит нужно менять подход. Дело в том, что старая версия не знала ничего о том, что было до нее, заново собирала всю информацию. Не умела определять список ферм и городов и вообще была не тру.

Так родилась вторая версия. Теперь для работы нужно всего-лишь указать sid(берется из кукисов через dev-tools, например) и сервер, на котором играете. Строится список городов/ферм автоматически. Хотя на двух городах еще не пробовал, у меня пока только один город, но следите за github репозиторием — фиксы будут выходить незамедлительно. Особенность новой версии в том, что она запоминает как можно больше данных и старается слать поменьше лишних запросов.

Сама игра тоже обновилась, если раньше клиенту посылался в основном просто html код, то теперь появились отдельные объекты, хотя присылать в поле json объекта другой json об’ект как строку тот еще ход. Например:
{
  'type' => 'backbone',
  'param_id' => 13980,
  'subject' => 'Units',
  'id' => 4414096,
  'param_str' => '{"Units":{"id":13980,"home_town_id":5391,"current_town_id":5391,"sword":23,"slinger":21,"archer":5,"hoplite":10,"rider":0,"chariot":0,"catapult":0,"minotaur":0,"manticore":0,"zyklop":0,"harpy":0,"medusa":0,"centaur":0,"pegasus":0,"cerberus":0,"fury":0,"griffin":0,"calydonian_boar":0,"godsent":34,"big_transporter":0,"bireme":0,"attack_ship":0,"demolition_ship":0,"small_transporter":0,"trireme":0,"colonize_ship":0,"sea_monster":0,"militia":0,"heroes":null,"home_town_link":"<a href=\\"#eyJpZCI6NTM5MSwiaXgiOjUxMSwiaXkiOjYyMywidHAiOiJ0b3duIiwibmFtZSI6IlBlcmwifQ==\\" class=\\"gp_town_link\\">Perl<\\/a>","same_island":true,"current_town_link":"<a href=\\"#eyJpZCI6NTM5MSwiaXgiOjUxMSwiaXkiOjYyMywidHAiOiJ0b3duIiwibmFtZSI6IlBlcmwifQ==\\" class=\\"gp_town_link\\">Perl<\\/a>","current_player_link":"<a  href=\\"#eyJuYW1lIjoiUGluZ3ZlaW4iLCJpZCI6e319\\" class=\\"gp_player_link\\">Pingvein<\\/a>"}}',
  'time' => 1383837485
}

Я создал файл «install_libraries.sh» для тех у кого debian/ubuntu, чтобы разрешить все зависимости. Другим же предлагается использовать cpan или репозитории своего дистрибутива. Весь код у меня однопоточный, потому что странно будет, если бот одновременно пошлет 2 запроса. И потоком этим заведует «IO::Async::Loop». Async.pm:

package GrepolisBotModules::Async;

use GrepolisBotModules::Log;

use IO::Async::Timer::Countdown;
use IO::Async::Loop;

my $loop = IO::Async::Loop->new;

sub delay{
	my($delay, $callback) = @_;

	GrepolisBotModules::Log::echo 1, "Start delay $delay \n";

	my $timer = IO::Async::Timer::Countdown->new(
		delay => $delay,
		on_expire => $callback,
	);
	 
	$timer->start;
	$loop->add( $timer );
}

sub run{
	$loop->later(shift);
	$loop->run;
}

1;

В этом модуле просто добавятся инициаторы событий в главный цикл. У меня все по таймеру, но код, что инициализирует приложение додается в методе «run». Обратите внимание, что я стараюсь время таймера высчитывать на основании функции rand, чтобы не палится. Главный файл — grepolis_bot.pl:

#!/usr/bin/perl

use strict;
use warnings;

use Config::IniFiles;

use GrepolisBotModules::Request;
use GrepolisBotModules::Town;
use GrepolisBotModules::Async;
use GrepolisBotModules::Log;

use utf8;

my $cfg = Config::IniFiles->new( -file => "config.ini" );
my $config = {
    security => {
        sid    => $cfg->val( 'security', 'sid' ),
        server => $cfg->val( 'security', 'server' )
    },
    global => {
        log    => $cfg->val( 'global', 'log' ),
    }
};
undef $cfg;

my $Towns = [];

GrepolisBotModules::Async::run sub{

    GrepolisBotModules::Request::init($config->{'security'});
    GrepolisBotModules::Log::init($config->{'global'});

    GrepolisBotModules::Log::echo(0, "Program started\n");

    my $game = GrepolisBotModules::Request::base_request('http://'.$config->{'security'}->{'server'}.'.grepolis.com/game');

    $game =~ /"csrfToken":"([^"]+)",/;
    GrepolisBotModules::Request::setH($1);
    $game =~ /"townId":(\d+),/;
    GrepolisBotModules::Log::echo 1, "Town $1 added\n";
    push($Towns, new GrepolisBotModules::Town($1));
};

Считываем конфиг, устанавливаем csrfToken для последующих запросов, и текущий город. Поддержка нескольких городов появится как только я захвачу новый город. Обещаю сделать это так быстро как только смогу.

Модуль для города, Town.pm:

package GrepolisBotModules::Town;

use strict;
use warnings;

use GrepolisBotModules::Request;
use GrepolisBotModules::Farm;
use GrepolisBotModules::Log;

use JSON;

my $get_town_data = sub {
    my( $self ) = @_;

    my $resp = JSON->new->allow_nonref->decode(
        GrepolisBotModules::Request::request(
                'town_info',
                'go_to_town',
                $self->{'id'},
                undef,
                0
            )
        );

    $self->{'max_storage'} = $resp->{'json'}->{'max_storage'};

    $resp = JSON->new->allow_nonref->decode(
        GrepolisBotModules::Request::request(
                'data',
                'get',
                $self->{'id'},
                '{"types":[{"type":"backbone"},{"type":"map","param":{"x":0,"y":0}}]}',
                1
            )
        );

    foreach my $arg (@{$resp->{'json'}->{'backbone'}->{'collections'}}) {
        if(
            defined $arg->{'model_class_name'} &&
            $arg->{'model_class_name'} eq 'Town'
        ){
            my $town = pop($arg->{'data'});
            $self->setResources($town->{'last_iron'}, $town->{'last_stone'}, $town->{'last_wood'});
        }
    }

    foreach my $data (@{$resp->{'json'}->{'map'}->{'data'}->{'data'}->{'data'}} ) {
        foreach my $key (keys %{$data->{'towns'}}) {
            if(
                defined $data->{'towns'}->{$key}->{'relation_status'} &&
                $data->{'towns'}->{$key}->{'relation_status'} == 1
            ){
                my $village = new GrepolisBotModules::Farm($data->{'towns'}->{$key}->{'id'}, $self);
                push($self->{'villages'}, $village);
            }
        }
    }
};

my $build_something;

$build_something = sub {
    my $self = shift;

    GrepolisBotModules::Log::echo 0, "Build request ".$self->{'id'}."\n";
    my $response_body = GrepolisBotModules::Request::request('building_main', 'index', $self->{'id'}, '{"town_id":"'.$self->{'id'}.'"}', 0);

    $response_body =~ m/({.*})/;

    my %hash = ( JSON->new->allow_nonref->decode( $1 )->{'json'}->{'html'} =~ /BuildingMain.buildBuilding\('([^']+)',\s(\d+)\)/g );
    my $to_build = '';
    
    if(defined $hash{'main'} && $hash{'main'}<25){
        $to_build = 'main';
    }elsif(defined $hash{'academy'}){
        $to_build = 'academy';
    }elsif(defined $hash{'farm'}){
        $to_build = 'farm';
    }elsif(defined $hash{'barracks'}){
        $to_build = 'barracks';
    }elsif(defined $hash{'storage'}){
        $to_build = 'storage';
    }elsif(defined $hash{'docks'}){
        $to_build = 'docks';
    }elsif(defined $hash{'stoner'}){
        $to_build = 'stoner';
    }elsif(defined $hash{'lumber'}){
        $to_build = 'lumber';
    }elsif(defined $hash{'ironer'}){
        $to_build = 'ironer';
    }
    if($to_build ne ''){
        my $response_body = GrepolisBotModules::Request::request(
            'building_main',
            'build',
            $self->{'id'},
            '{"building":"'.$to_build.'","level":5,"wnd_main":{"typeinforefid":0,"type":9},"wnd_index":0,"town_id":"'.$self->{'id'}.'"}',
            1
        );
    }

    my $time_wait = undef;

    my $json = JSON->new->allow_nonref->decode($response_body);
    if(defined $json->{'notifications'}){
        foreach my $arg (@{$json->{'notifications'}}) {
            if(
                $arg->{'type'} eq 'backbone' &&
                $arg->{'subject'} eq 'BuildingOrder'
            ){
                my $order = JSON->new->allow_nonref->decode($arg->{'param_str'})->{'BuildingOrder'};
                $time_wait = $order->{'to_be_completed_at'} - $order->{'created_at'};
            }
        }
    }

    if(defined $time_wait){
        GrepolisBotModules::Log::echo 0, "Town ".$self->{'id'}." build ".$to_build."\n";
        GrepolisBotModules::Async::delay( $time_wait + int(rand(60)), sub {$self->$build_something} );
    }else{
        GrepolisBotModules::Log::echo 0, "Town ".$self->{'id'}." can not build. Waiting\n";
        GrepolisBotModules::Async::delay( 600 + int(rand(300)), sub {$self->$build_something} );
    }
};

sub setResources{
    my $self = shift;
    my $iron = shift;
    my $stone = shift;
    my $wood = shift;

    $self->{'iron'} = $iron;
    $self->{'wood'} = $wood;
    $self->{'stone'} = $stone;

    GrepolisBotModules::Log::echo 1, "Town ".$self->{'id'}." resources updates iron-".$self->{'iron'}.", stone-".$self->{'stone'}.", wood-".$self->{'wood'}."\n";
}

sub needResources{
    my $self = shift;
    my $resources_by_request = shift;

    if(
        $self->{'iron'} + $resources_by_request < $self->{'max_storage'} ||
        $self->{'wood'} + $resources_by_request < $self->{'max_storage'} ||
        $self->{'stone'} + $resources_by_request < $self->{'max_storage'}
    ){
        return 1;
    }
    return 0;
}

sub toUpgradeResources{
    my $self = shift;

    return {
        wood => int($self->{'iron'}/5),
        stone => int($self->{'wood'}/5),
        iron => int($self->{'stone'}/5),
    };
}

sub getId{
    my $self = shift;
    return $self->{'id'};
}

sub new {
    my $class = shift;
    my $self = {
        id => shift,
        villages => [],
        max_storage => undef,
        iron => undef,
        wood => undef,
        stone => undef
     };

    bless $self, $class;
    
    GrepolisBotModules::Log::echo 0, "Town ".$self->{'id'}." init started\n";

    $self->$get_town_data;
    GrepolisBotModules::Log::echo 0, "Town ".$self->{'id'}." data gettings finished\n";
    $self->$build_something;
    GrepolisBotModules::Log::echo 0, "Town ".$self->{'id'}." build started\n";

    return $self;
}

1;


Он при инициализации считывает ресурсы, которыми располагает, ищет фермы, с которых может требовать дань, и объем склада. Так же обратите внимание на процедуру «build_something». Я особо не задумывался над какой-то особой стратегией постройки, поэтому можете изменить приоритет строительства так, как посчитаете нужным. Модуль для «ферм» (так называемых крестьянских поселений) Farm.pm:

package GrepolisBotModules::Farm;

use GrepolisBotModules::Request;
use GrepolisBotModules::Log;

use JSON;

my $get_farm_data = sub {
	
	my $self = shift;

    my $resp = JSON->new->allow_nonref->decode(
        GrepolisBotModules::Request::request(
                'farm_town_info',
                'claim_info',
                $self->{'town'}->getId,
                '{"id":"'.$self->{'id'}.'"}',
                0
            )
        );

    $self->{'name'} = $resp->{'json'}->{'json'}->{'farm_town_name'};
    $resp->{'json'}->{'html'} =~ /<h4>You\sreceive:\s\d+\sresources<\/h4><ul><li>(\d+)\swood<\/li><li>\d+\srock<\/li><li>\d+\ssilver\scoins<\/li><\/ul>/;
    $self->{'resources_by_request'} = $1;
    if($resp->{'json'}->{'html'} =~ /<h4>Upgrade\slevel\s\((\d)\/6\)<\/h4>/ ){
        $self->{'level'} = $1;
    }else{
        die('Level not found');
    }
};

my $upgrade = sub{
	my $self = shift;

	my $donate = $self->{'town'}->toUpgradeResources();

    $json = '{"target_id":'.$self->{'id'}.',"wood":'.$donate->{'wood'}.',"stone":'.$donate->{'stone'}.',"iron":'.$donate->{'iron'}.',"town_id":"'.$self->{'town'}->getId().'"}';
    my $response_body = GrepolisBotModules::Request::request('farm_town_info', 'send_resources', $self->{'town'}->getId(), $json, 1);
    GrepolisBotModules::Log::echo 1, "Village send request. Town ID ".$self->{'town'}->getId()." Village ID ".$self->{'id'}."\n";

    $self->$get_farm_data;
};
my $claim = sub{
	my $self = shift;
	$json = '{"target_id":"'.$self->{'id'}.'","claim_type":"normal","time":300,"town_id":"'.$self->{'town'}->getId.'"}';
    my $response_body = GrepolisBotModules::Request::request('farm_town_info', 'claim_load', $self->{'town'}->getId, $json, 1);

    my $json = JSON->new->allow_nonref->decode($response_body)->{'json'};
    if(defined $json->{'notifications'}){
        foreach my $arg (@{$json->{'notifications'}}) {
            if(
                $arg->{'type'} eq 'backbone' &&
                $arg->{'subject'} eq 'Town'
            ){
                my $town = JSON->new->allow_nonref->decode($arg->{'param_str'})->{'Town'};
                $self->{'town'}->setResources($town->{'last_iron'}, $town->{'last_stone'}, $town->{'last_wood'});
            }
        }
    }

    GrepolisBotModules::Log::echo 1, "Farm ".$self->{'id'}." claim finished\n";
};

my $needUpgrade = sub {
	my $self = shift;
	if($self->{'level'} < 6){
		return true;
	}else{
		return false;
	}
};

my $tick;
$tick = sub {

	my $self = shift;

	if($self->{'town'}->needResources($self->{'resources_by_request'})){
		$self->$claim();
	    GrepolisBotModules::Async::delay( 360 + int(rand(240)), sub { $self->$tick} );
	}elsif($self->$needUpgrade()){
		$self->$upgrade();
		GrepolisBotModules::Async::delay( 600 + int(rand(240)), sub { $self->$tick} );
    }
};

sub new {
    my $class = shift;

    my $self = {
        id => shift,
        name => undef,
        resources_by_request => undef,
        town => shift,
        level => undef
    };
    GrepolisBotModules::Log::echo 0, "Farm ".$self->{'id'}." init started\n";
    bless $self, $class;
    
    $self->$get_farm_data;
    GrepolisBotModules::Log::echo 0, "Farm ".$self->{'id'}." data gettings finished\n";
    $self->$tick;
    GrepolisBotModules::Log::echo 0, "Farm ".$self->{'id'}." ticker started\n";

    return $self;
}

1;

Ферма, при инициализации считывает свой уровень и количество ресурсов, отдаваемых каждые 5 минут. Чтобы сэкономить на запросах, я проверяю, действительно нужны ли городу эти ресурсы, если нет, проверяю, можно ли улучшить текущее поселение, чтобы оно давало больше ресурсов за раз. После того, как с поселения затребованы ресурсы, я проверяю уведомления, и их на основании задаю городу значения ресурсов, чтобы не посылать для этого отдельных запросов. После каждого улучшения, обновлятется информация о поселении. Еще напишу про один фрагмент из модуля, что отвечает за посылку запросов на сервер, Request.pm:

if($response_body =~ /^{/){
    my $json = JSON->new->allow_nonref->decode( $response_body )->{'json'};
    if(defined $json->{'notifications'}){
        foreach my $arg (@{$json->{'notifications'}}) {
            if(
                (
                    $arg->{'type'} ne 'building_finished' &&
                    $arg->{'type'} ne 'newreport' &&
                    (
                        $arg->{'type'} ne 'backbone' ||
                        $arg->{'type'} eq 'backbone' && 
                        (
                            !(defined $arg->{'subject'}) ||
                            (
                                $arg->{'subject'} ne 'BuildingOrder' &&
                                $arg->{'subject'} ne 'Town' &&
                                $arg->{'subject'} ne 'PlayerRanking' &&
                                $arg->{'subject'} ne 'Buildings' &&
                                $arg->{'subject'} ne 'IslandQuest' &&
                                $arg->{'subject'} ne 'TutorialQuest'
                            )
                        )
                    )
                )
            ){
                GrepolisBotModules::Log::dump 5, $arg;
            }
        }
    }
}

Я проверяю нотификации, чтобы выделить одну, интересующую меня. А именно запрос на введение капчи. Вообще-то я планирую считать запросы до возникновения необходимости вводить капчу, чтобы ограничивать активность бота. Еще планируется «ночной режим» — чтобы бот не отсылал в ночное время запросы. Хотя, если склады будут полны а очередь строительства полна долгими заданиями, то запросы и так слаться не будут.

В игре первый город универсальный, но потом нужно разделять на города, что стоят морскую атакующую армию, морскую защитную и сухопутные атакующую/защитную. В зависимости от типа города, в нем проводится постройка разных зданий, для экономии свободного населения, и разная научная политика. Буду рад увидеть комментарии, стоит ли реализовывать автопостройку армий, зданий, автоисследование в зависимости от города или оставить бота как простого автосборщика. Еще мне интересно, будет ли полезной функция отправки войск по расписанию.

С удовольствием отвечу в комментариях о особенностях сервера Grepolis, которые мне удалось обнаружить.
Tags:
Hubs:
+17
Comments23

Articles