26 Ноя, 2016

Пилим ForThisTime.ru #1 — World Of Tanks

Захотелось написать сервис, показывающий сколько времени я потратил на World Of Tanks. Пока разбирался с API и математикой рассчетов понял, что этого мало. Сервис ForThisTime будет показывать, что можно было сделать за потраченное на что либо время, а танковая статистика будет перенесена на поддомен wot.forthistime.ru.

В статье разберем WOT API, запросы, базу данных и математические выкладки. Весь backend и вычисления выполнены на голом Python, пока не понадобились даже Numpy и Pandas.

Задачи и решения
Внутриигровые детали
Как проверить (dossier)
Что будем хранить в базе
Модели
Как устроен WOT API
URls
Пишем запросы и функции
Добавление информации в базу
Django Commands
Как запускать свои скрипты на сервере Django
Анализ
В следующей серии

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

Внутриигровые детали и рассчеты
World Of Tanks это краткосессионный (15 минут) PvP MMO-action. Есть несколько режимов игры, но нас интересует только Random Battles. Это основной режим, в нем участвуют две команды из 15 псевдослучайных игроков.

Если игрока убили он может либо досматривать бой, либо выйти и выйти в новый бой  на другом танке. В зависимости от скилла и класса техники игрока среднее время боя для него варьируется от 5 до 10 минут.

У всех танков разные роли на разных картах и в разных ситуациях. Главные показатели игрока — винрейт (50% и ниже — неопытный, 52%-54% — хороший игрок, больше — скилловые типы), средний урон (самая важная боевая характеристика, более всего влияет на бой) и рейтинги эффективности (более сложные формулы, на них все ровняются).

Мне нужны только примерные величины, поэтому я просто решил вычислить количество боев игрока на разных классах техники и умножить их на среднее время боя этого класса, согласно неофициальной статистики. ЛТ — 5 минут, СТ, ТТ и ПТ — 7 минут, арта — 10.

Это очень грубые расчеты, погрешность в таком случае 20-40%, что вполне допустимо для нашей задачи, хоть и некрасиво. Будем подгонять позже.

Как проверить расчеты
Как понятно из всей статьи, battle time это недоступная игроку статистика. Полагаю, это связано с основной аудиторией игры — мужчины старше 40 лет. Это действительно взрослые люди, которые могут покинуть игру, если увидят свои 500+ часов геймплея.

Но такая статистика ведется игрой для аналитики. Она доступно в файле-досье в папке с игрой, где описана вся подробная статистика игрока. То есть файл может открыть только хозяин компьютера, на котором установлен клиент игры, в который выполнен вход на аккаунт игрока. Я использовал этот файл, чтоб узнать свой battle time и подогнать формулу расчета.

Открыть этот файл можно на специальном сайте. А лучше пойти на сайт vBaddict и загрузить файл туда, для сбора неофициальной статистики. На этом же сайте можно ознакомиться с другими интересностями — тепловые карты позиций игроков, данные о длительности боях на разных картах и танках, средние показатели разной техники.

Что будем хранить в базе
Мне было очень интересно создать свою базу данных для быстрого анализа, поэтому я решил хранить всю полученную при расчетах информацию. Для корректного анализа нужна правильная и равномерная выборка игроков, поэтому я решил вносить в базу каждого десятого игрока (по id), от первого до последнего (их больше 30 000 000 на момент написания статьи).

Модели Django
Прежде чем обсуждать WOT API создадим модели Player, Tank и Having. Последняя необходима для создания связей между игроками и танками (чтобы можно было вычислить, сколько игроков имеют данный танк и наоборот):

class Tank(models.Model):
    name = models.CharField(max_length=100, blank=True)
    level = models.IntegerField(default=0, blank=True)
    nation = models.CharField(default=0, max_length=15, blank=True)
    type = models.CharField(default='', max_length=15, blank=True)
    id = models.IntegerField(primary_key=True)
    image = models.URLField(default='', blank=True)
    is_premium = models.BooleanField(default=0, blank=True)

    circular_vision_radius = models.IntegerField(default=0, blank=True)
    max_health = models.IntegerField(default=0, blank=True)
    price_credit = models.IntegerField(default=0, blank=True)
    speed_limit = models.IntegerField(default=0, blank=True)
    weight = models.FloatField(default=0, blank=True)


class Player(models.Model):
    nickname = models.CharField(max_length=63)
    id = models.IntegerField(primary_key=True)
    global_rating = models.IntegerField(default=0, blank=True)

    battle_time = models.IntegerField(default=0, blank=True)

    battles_total = models.IntegerField(default=0, blank=True)
    battles_light = models.IntegerField(default=0, blank=True)
    battles_medium = models.IntegerField(default=0, blank=True)
    battles_heavy = models.IntegerField(default=0, blank=True)
    battles_at = models.IntegerField(default=0, blank=True)
    battles_spg = models.IntegerField(default=0, blank=True)

    tanks = models.ManyToManyField(Tank, through='Having')


class Having(models.Model):
    player = models.ForeignKey(Player, on_delete=models.CASCADE)
    tank = models.ForeignKey(Tank, on_delete=models.CASCADE)
    battles = models.IntegerField(default=0)


Как устроен WOT API
Нормально!

Это мой первый игровой API и он мне понравился. Хорошая документация, приемлемая архитектура и даже онлайн-обозреватель с описанием всех полей запроса. Ниже указан application_id, который вам нужно получить самим на сайте. Это делается в один клик. Свой application_id вставляйте в соответсвующее поле.

Нам необходимы следующие URL:

  • https://api.worldoftanks.ru/wot/account/list/ — запрос по этому URL возвращает список игроков. Мы будем искать nickname, а сервер вернет нам (если найдет) account_id игрока.
  • https://api.worldoftanks.ru/wot/account/info/ — здесь можно достать информацию по игроку, как общую, так и приватную. Последняя нам не понадобится — для нее требуется авторизация.
  • https://api.worldoftanks.ru/wot/account/tanks/ — техника игрока и статистика по ней.
  • https://api.worldoftanks.ru/wot/encyclopedia/tanks/ — все танки игры с основной информацией.
  • https://api.worldoftanks.ru/wot/encyclopedia/tankinfo/ — более детальная информация по конкретной машине.


Пишем запросы и функции
Весь алгоритм получения нужной информации следующий: сначала вносим в базу всю информацию о всех танках (порядка 400-500 машин), по никнейму игрока получаем его account_id, получаем всю его технику и статистику по ней, смотрим количество боев на каждом танке, суммируем все бои для каждого класса техники, считаем итоговое время по формуле.

Запрос всех танков (models.py):

from django.db import models
from django.shortcuts import get_object_or_404

import sys
import time
import urllib
import urllib.request
import urllib.parse
import urllib.request as urllib2
import json

def update_all_tanks():
    start = time.time()
    all_tanks_url = 'https://api.worldoftanks.ru/wot/encyclopedia/tanks/'
    info_tank_url = 'https://api.worldoftanks.ru/wot/encyclopedia/tankinfo/'

    all_tanks_fields = {'application_id': 'ВАШ APPLICATION_ID',
                        'fields': 'type, short_name_i18n, level, nation, is_premium, image, tank_id ',
                        'language': 'ru'}

    data = urllib.parse.urlencode(all_tanks_fields)
    data = data.encode('utf-8')
    req = urllib2.Request(all_tanks_url, data)

    response = urllib2.urlopen(req)  # ответ
    the_page = response.read()  # чтение ответа, байты
    the_page = the_page.decode('utf-8')  # ответ to str
    parsed = json.loads(the_page)  # str to json
#   print(json.dumps(parsed, indent=3, sort_keys=True))
    parsed = parsed['data'] #в ответе сервера есть два ключа: 'data' и 'status'. нам нужен только последний

#   Получаем дополнительные характеристики танков
    for tank in parsed:
        info_tank_fields = {'application_id': 'ВАШ APPLICATION_ID',
                            'tank_id': str(tank),
                            'fields': 'limit_weight, speed_limit, price_credit, max_health, circular_vision_radius',
                            'language': 'ru'}

        data = urllib.parse.urlencode(info_tank_fields)
        data = data.encode('utf-8')
        req = urllib2.Request(info_tank_url, data)

        response = urllib2.urlopen(req)  # ответ
        the_page = response.read()  # чтение ответа, байты
        the_page = the_page.decode('utf-8')  # ответ to str
        parsed_info = json.loads(the_page)  # str to json
#       print(json.dumps(parsed_info, indent=3, sort_keys=True))
        parsed_info = parsed_info['data']

#       Создаем объект
        Tank.objects.update_or_create(id=tank,
                                      circular_vision_radius=parsed_info[tank]['circular_vision_radius'],
                                      max_health=parsed_info[tank]['max_health'],
                                      price_credit=parsed_info[tank]['price_credit'],
                                      speed_limit=parsed_info[tank]['speed_limit'],
                                      weight=parsed_info[tank]['limit_weight'],
                                      name=parsed[tank]['short_name_i18n'],
                                      level=parsed[tank]['level'], type=parsed[tank]['type'],
                                      image=parsed[tank]['image'], is_premium=parsed[tank]['is_premium'],
                                      nation=parsed[tank]['nation']
                                      )

    end = time.time()
    print(end - start)

Все импорты файла models.py указаны выше. Запрос account_id по nickname:

def get_player_id(nickname):
    start = time.time()
    search_url = 'https://api.worldoftanks.ru/wot/account/list/'
    search_fields = {'application_id': 'ВАШ APPLICATION_ID',
                     'search': nickname}

    data = urllib.parse.urlencode(search_fields)
    data = data.encode('utf-8')
    req = urllib2.Request(search_url, data)
    response = urllib2.urlopen(req)

    the_page = response.read()
    the_page = the_page.decode('unicode-escape')
    # print(the_page)
    parsed = json.loads(the_page)

    if parsed['status'] == 'ok':
        account_id = parsed['data'][0]['account_id']
        # print(account_id)
        # print(json.dumps(parsed, indent=3, sort_keys=True))

        Player.objects.update_or_create(id=account_id, nickname=nickname)

        end = time.time()

        #print('Username {} (ID {}) was updated. Time: {}'.format(str(nickname), str(account_id), str((end - start))))
        return account_id
    else:
        print('Can`t get account_id, status is: %S', parsed['status'])

Получаем краткую информацию об игроке для статистики:

def get_player_info(account_id):
    start = time.time()
    search_url = 'https://api.worldoftanks.ru/wot/account/info/'
    search_fields = {'application_id': 'ВАШ APPLICATION_ID',
                     'account_id': account_id, 'fields': 'nickname,'
                                                         'global_rating, statistics.all.battles'}

    data = urllib.parse.urlencode(search_fields)
    data = data.encode('utf-8')
    req = urllib2.Request(search_url, data)
    response = urllib2.urlopen(req)

    the_page = response.read()
    the_page = the_page.decode('unicode-escape')
    #print(the_page)
    parsed = json.loads(the_page)

    if parsed['status'] == 'ok' and parsed['data'][str(account_id)]\
            and parsed['data'][str(account_id)]['statistics']['all']['battles']:

        nickname = parsed['data'][str(account_id)]['nickname']
        global_rating = parsed['data'][str(account_id)]['global_rating']
        battles = parsed['data'][str(account_id)]['statistics']['all']['battles']

        # print(account_id)
        # print(json.dumps(parsed, indent=3, sort_keys=True))

        try:
            player = Player.objects.get(id=account_id)
            player.nickname = nickname
            player.global_rating = global_rating
            player.battles_total = battles
            player.save()
            end = time.time()
            print('Username {} (ID {}) was updated. Time: {}'.format(str(nickname), str(account_id), str((end - start))))

        except Player.DoesNotExist:
            Player.objects.create(id=account_id, nickname=nickname, global_rating=global_rating,
                                  battles_total=battles)
            end = time.time()
            print('Username {} (ID {}) was created. Time: {}'.format(str(nickname), str(account_id), str((end - start))))

        return account_id
    else:
        if parsed['status'] == 'ok':
            print('ID {} does not exist'.format(account_id))
        else:
            print('Can`t get ID {}, status is: {}'.format(account_id, parsed['status']))

Получаем все танки игрока, связываем Player и Tank с помощью Having:

def get_players_tanks(account_id):
    try:
        Player.objects.get(id=account_id)
        player = Player.objects.get(id=account_id)
        start = time.time()
        tanks_url = 'https://api.worldoftanks.ru/wot/account/tanks/'
        tanks_fields = {'application_id': 'ВАШ APPLICATION_ID',
                        'account_id': account_id, 'fields': 'tank_id, statistics.battles'}

        data = urllib.parse.urlencode(tanks_fields)
        data = data.encode('utf-8')
        req = urllib2.Request(tanks_url, data)
        response = urllib2.urlopen(req)  # ответ
        the_page = response.read()  # чтение ответа, байты

        the_page = the_page.decode('unicode-escape')  # ответ to str
        parsed = json.loads(the_page)  # str to json

        if parsed['status'] == 'ok':
            parsed = parsed['data'][str(account_id)]
            #print(json.dumps(parsed, indent=3, sort_keys=True))
            players_tanks = {}

            for tank in parsed:
                try:
                    tank_db = Tank.objects.get_or_create(id=tank['tank_id'])[0]
                    Having.objects.update_or_create(tank=tank_db, player=player,
                                                    battles=int(tank['statistics']['battles']))
                except:
                    print(' Can`t get tank (ID {})'.format(tank['tank_id']), sys.exc_info()[0])
                    raise

            # print(json.dumps(players_tanks, indent=3, sort_keys=True))  # pretty json

            end = time.time()

            print(' Get tanks of player (ID {}) Time: {}'.format(str(account_id), str((end - start))))
        else:
            print(' Can`t get tanks, status is: %S', parsed['status'])
    except Player.DoesNotExist:
        print(' User ID {} not exist in database'.format(account_id))


Django commands
Теперь, когда в базе есть все нужные данные для анализа, можно написать первые скрипты. В Django есть удобный встроенный элемент — кастомные команды. В приложении (web, в моем случае) создаем директорию managment, в ней папку commands. Здесь описываем в файлах .py свои команды, которые потом можно будет запускать в терминале как python3 manage.py command.

Я использую этот способ всегда, если алгоритм работает в пределах юрисдикции Django. Обычные bash-скрипты будут тяжеловато работать с данными и моделями, в то время как Django commands не требует вообще никаких телодвижений вроде авторизаций и запуска python shell.

Как запускать свои скрипты на сервере Django
Напишем команду python3 manage.py add_players 1 1000, которая добавит в базу игроков с account_id от единицы до тысячи. Если они существуют. Пример файла add_players.py:

from django.core.management.base import BaseCommand
from web.models import get_player_info
import time


class Command(BaseCommand):
    help = 'Add players by id'

    def add_arguments(self, parser):
        parser.add_argument('player_from', nargs='+', type=int) #первый аргумент команды
        parser.add_argument('player_to', nargs='+', type=int) #второй

#  Сама команда
    def handle(self, *args, **options):
        start_all = time.time()
        for player_from in options['player_from']:
            for player_to in options['player_to']:
                for player_id in range(player_from, player_to, 10):
                    get_player_info(player_id)

        end_all = time.time()
        print('{} users was added. Time: {}'.format(player_to, (end_all-start_all)))

По аналогии, напишем первую команду поверхностного анализа. analysis будет показывать, сколько игроков наиграло сколько боев. Эти данные я собираю из-за распространенного мнения о том, что активная аудитория World Of Tanks это всего 20% от 30.000.000 пользователей. Проверим:

from django.core.management.base import BaseCommand
from web.models import Player
import time
from tabulate import tabulate


class Command(BaseCommand):
    help = 'Add players by id'

    def handle(self, *args, **options):
        start_all = time.time()

        players_all = Player.objects.count()
        playersnot1000 = 0
        players1000 = 0
        players10000 = 0
        players100000 = 0

        for player in Player.objects.all():
            if player.battles_total > 100000:
                players100000 += 1

            if player.battles_total > 10000:
                players10000 += 1

            if player.battles_total > 1000:
                players1000 += 1
            else:
                playersnot1000 += 1

        print('Statistics for players:\n')
        print(tabulate([['All players', players_all, 100],
            ['Players under 1000 battles:', playersnot1000, (playersnot1000*100)/players_all],
            ['Players over 1000 battles:', players1000, (players1000*100)/players_all],
            ['Players over 10000 battles:', players10000, (players10000*100)/players_all],
            ['Players over 100000 battles:', players100000, (players100000*100)/players_all]]))

        end_all = time.time()
        print('Time: {}'.format((end_all-start_all)))

Библиотечка tabulate нам нужна для того, чтобы красиво вывести данные. Имея в базе каждого десятого пользователя с id от 1 до 8.400.000 имеем слудеющие данные:

>> python3 manage.py analysis

Statistics for players:
----------------------------  ------  ------------
All players                   673453  100%
Players under 1000 battles:   341598   50.7234%
Players over 1000 battles:    331855   49.2766%
Players over 10000 battles:   164354   24.4047%
Players over 100000 battles:      36    0.00534558%
----------------------------  ------  ------------
Time: 19.7128164768219

В следующей серии:
— Дополним данные до полноценной правильной выборки (от 1 до 30.000.000).
— Допишем analysis.py (будем считать больше данных).
— Напишем функцию, считающую battle_time. Иначе чо мы тут собрались вообще.
— Начнем работать над frontend.